Skip to main content

supercode/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::event::EventSink;
5
6/// The default OpenRouter base URL. Any OpenAI-compatible endpoint works too.
7pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
8
9/// Environment variable consulted for the API key when none is set explicitly.
10pub const DEFAULT_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
11
12/// P4 (design §5.2 "P4": "deny-rule patterns generalizing
13/// auto_approved_tools"): a minimal glob matcher — `*` matches any
14/// sequence (including empty), every other byte matches literally. No
15/// character classes, no `?`, no escaping — the smallest form that lets
16/// [`Config::tool_deny_patterns`]/[`Config::tool_allow_patterns`] express
17/// prefix/suffix/contains rules (`"bash*"`, `"mcp__github__*"`) without
18/// inventing a bigger pattern language than the S-sized scope calls for.
19/// This matches tool NAMES only — argument/command-level patterns (e.g.
20/// `bash(rm -rf*)`) are the P5 `capabilities.permissions.rules` engine's
21/// job (§2.1 dependency 3: command canonicalization is a hard prerequisite
22/// for THOSE, not for this).
23///
24/// LOW-2 (Fable-5 P4a review): this used to be the textbook-naive recursive
25/// matcher (`Some(b'*') => inner(&p[1..], t) || (!t.is_empty() &&
26/// inner(p, &t[1..]))`), which backtracks exponentially on a pattern with
27/// many `*`s against a text with no matching suffix (e.g. `"*a*a*a*a*a*a*a*
28/// a*a*a*b"` against a long run of `a`s) — since `tool_deny_patterns`/
29/// `tool_allow_patterns` can be project-controlled (via `capabilities.
30/// permissions.rules.deny`, which a project may only ADD to, never
31/// replace — see `merge_permissions_capability` in `configfile.rs`), a
32/// crafted deny pattern was a self-DoS on every single tool call. Rewritten
33/// as the standard iterative two-pointer wildcard-matching algorithm
34/// (record the position of the last `*` seen and how much of `text` it has
35/// consumed so far; on a literal mismatch, backtrack to just after that `*`
36/// and advance its consumption by one instead of recursing) — linear in
37/// `pattern.len() * text.len()`, no recursion, same match semantics as
38/// before (verified in `config.rs`'s test suite).
39pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
40    let p = pattern.as_bytes();
41    let t = text.as_bytes();
42    let (mut pi, mut ti) = (0usize, 0usize);
43    // Index of the most recent `*` in `p`, and how many bytes of `t` it had
44    // already been allowed to consume the last time we backtracked to it.
45    let mut star: Option<(usize, usize)> = None;
46
47    while ti < t.len() {
48        if pi < p.len() && p[pi] == b'*' {
49            star = Some((pi, ti));
50            pi += 1;
51        } else if pi < p.len() && p[pi] == t[ti] {
52            pi += 1;
53            ti += 1;
54        } else if let Some((star_pi, star_ti)) = star {
55            // Backtrack: let the last `*` consume one more byte of `t`.
56            let new_star_ti = star_ti + 1;
57            star = Some((star_pi, new_star_ti));
58            pi = star_pi + 1;
59            ti = new_star_ti;
60        } else {
61            return false;
62        }
63    }
64    // Any trailing `*`s in the pattern match the empty remainder.
65    while pi < p.len() && p[pi] == b'*' {
66        pi += 1;
67    }
68    pi == p.len()
69}
70
71/// Built-in prompt templates (slash commands), e.g. `/code-review`.
72fn default_prompts() -> std::collections::HashMap<String, String> {
73    let mut m = std::collections::HashMap::new();
74    m.insert(
75        "code-review".to_string(),
76        "Review the current code changes for correctness bugs, then for \
77reuse/simplification/efficiency cleanups. {args}\nUse the available tools to \
78inspect the diff and files. Report findings grouped by severity."
79            .to_string(),
80    );
81    m
82}
83
84/// A default, deliberately small system prompt. Override it freely.
85pub const DEFAULT_SYSTEM_PROMPT: &str = "\
86You are supercode, a precise and efficient AI coding agent operating in a user's \
87working directory. Use the available tools to inspect and modify files and run \
88commands. Prefer reading before writing. Make minimal, correct changes and explain \
89what you did concisely.";
90
91/// When the agent must seek approval before running a tool — the analog of
92/// Codex's `-a untrusted|on-request|never` and Claude's permission modes.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum ApprovalPolicy {
95    /// Never ask — every tool call runs automatically (default).
96    #[default]
97    Never,
98    /// Ask for tools not on the auto-approve allowlist.
99    OnRequest,
100    /// Ask for every tool call.
101    Untrusted,
102    /// P5-1 (COMPOSABLE-HARNESS-DESIGN.md §3.2 S8, §4.3 cx-parity): Codex's
103    /// `-a on-request` default — escalation is INITIATED BY THE MODEL, not
104    /// decided by a client-side allowlist check the way [`Self::OnRequest`]
105    /// is (`Config::needs_approval`'s `OnRequest` arm consults
106    /// `Config::auto_approved_tools`/`tool_allow_patterns`; Codex's
107    /// `on-request` instead runs sandboxed writes/reads silently and only
108    /// asks when the MODEL itself requests to leave the sandbox —
109    /// `protocol.rs:921-924`). Using [`Self::OnRequest`] for cx-parity would
110    /// prompt on every non-allowlisted call, where stock Codex prompts
111    /// almost never — a materially different (over-prompting, but not
112    /// unsafe) posture, which is why `configfile::parse_approval_str`
113    /// previously fell back to [`Self::Untrusted`] rather than silently
114    /// picking the wrong existing variant (S8's original fail-safe). This
115    /// variant now exists so cx-parity resolves to its INTENDED posture
116    /// instead of that fail-safe. `Config::needs_approval` (the coarse,
117    /// tool-name-only legacy gate — no model-escalation signal reaches it)
118    /// treats this conservatively, the same as [`Self::OnRequest`]; the P5-1
119    /// permissions engine (`crate::permissions`, the richer canonicalized-
120    /// command-aware gate `crate::agent::Agent` consults when
121    /// `Config::permissions_enabled` is on) treats it per Codex's real
122    /// posture — see that gate's doc comment.
123    ModelRequested,
124}
125
126/// A callback consulted when a tool call needs approval. Returns `true` to allow.
127pub type ApprovalHandler = Box<dyn Fn(&crate::message::ToolCall) -> bool + Send + Sync>;
128
129/// A pre-tool hook: receives the tool name and parsed arguments before
130/// execution. Return `Some(reason)` to BLOCK the call (the reason is fed back to
131/// the model), or `None` to allow it.
132pub type PreToolHook = Box<dyn Fn(&str, &serde_json::Value) -> Option<String> + Send + Sync>;
133
134/// A post-tool hook: receives the tool name, its output, and whether it errored,
135/// after execution (observational — logging, metrics, side effects).
136pub type PostToolHook = Box<dyn Fn(&str, &str, bool) + Send + Sync>;
137
138/// How tools are advertised to the model (B6, D16).
139///
140/// `Full` sends every enabled tool's schema on every request (today's
141/// behavior). `Deferred` advertises only a `core` allowlist plus a synthetic
142/// `tool_search` meta-tool; everything else — the MCP surface above all,
143/// since `McpTool::from_client` eagerly wraps every remote tool with its full
144/// `input_schema` — is discoverable via `tool_search` and only advertised
145/// (on the *next* request) once activated.
146#[derive(Debug, Clone, Default)]
147pub enum ToolAdvertising {
148    /// All enabled tools every request (today's behavior).
149    #[default]
150    Full,
151    /// Only `core` tools + the `tool_search` meta-tool; everything else is
152    /// discoverable via `tool_search` and advertised only after activation.
153    Deferred {
154        /// Tool names advertised eagerly on every request.
155        core: Vec<String>,
156    },
157}
158
159/// Prompt-caching plan applied at the request-build site (B7, SPEC.md D9).
160///
161/// `Off` is byte-identical to today's behavior: whatever implicit caching the
162/// provider does on its own still applies, but supercode never annotates
163/// anything. `ImportedPrefix` places Anthropic-style `cache_control` "ephemeral"
164/// breakpoints (message-level, not the top-level `extra_body` passthrough —
165/// see `provider::apply_cache_plan`) on the system message and the last
166/// message of a previously-imported session prefix
167/// ([`crate::Agent::load_session`]), so the large byte-stable prefix a resumed
168/// session resends every turn becomes a cache hit instead of a full re-read.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
170pub enum CachePlan {
171    /// No cache annotation (today's behavior). Default outside reduced mode.
172    #[default]
173    Off,
174    /// Breakpoints on the system message and the last message of the
175    /// imported prefix (at most 2 of Anthropic's 4 available slots).
176    ImportedPrefix,
177}
178
179/// Optional-policy gates resolved from `[capabilities.reduction]`.
180///
181/// `None` preserves [`crate::reduce::ReductionPolicy`]'s established
182/// default for callers that use reduced mode without the composable module
183/// surface. A preset or direct capability setting supplies only the gates it
184/// names; the CLI applies them when it constructs the live policy.
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
186pub struct ReductionPolicySettings {
187    pub stale_reads: Option<bool>,
188    pub diff_reads: Option<bool>,
189    pub duplicates: Option<bool>,
190    pub tool_input_elision: Option<bool>,
191    pub supersede: Option<bool>,
192    pub normalize_output: Option<bool>,
193    pub image_redaction: Option<bool>,
194    pub span_summaries: Option<bool>,
195}
196
197/// Per-tool customization: enable/disable a tool and/or override the description
198/// the model sees for it.
199#[derive(Debug, Clone, Default)]
200pub struct ToolOverride {
201    /// If `Some(false)`, the tool is hidden from the model entirely.
202    pub enabled: Option<bool>,
203    /// If `Some`, replaces the tool's built-in description in the schema.
204    pub description: Option<String>,
205    /// If `Some`, overrides [`Config::tool_schema_tier`] (TR-8/T5) for this
206    /// specific tool — e.g. keep one fat MCP tool at `Full` while the global
207    /// knob shrinks everything else to `Minimal`.
208    pub schema_tier: Option<crate::tools::SchemaTier>,
209    /// P4e (design §3.1/§S14 `core.tools.bash.timeout_secs`): the DEFAULT
210    /// execution timeout (seconds) for the `bash` tool when a model-issued
211    /// call doesn't supply its own `timeout_ms` argument — see
212    /// `tools::builtins::BashTool::execute`'s precedence (an explicit
213    /// per-call `timeout_ms` always wins; this only replaces the BUILT-IN
214    /// `DEFAULT_BASH_TIMEOUT_MS` fallback). Only meaningful on the `bash`
215    /// entry; other tools ignore it. `None` (the default) is byte-identical
216    /// to today's behavior — `BashTool`'s internal 120s default stands.
217    pub timeout_secs: Option<u64>,
218}
219
220/// Everything that shapes an [`crate::Agent`]: the model and endpoint, the
221/// credentials, sampling parameters, the system prompt, and per-tool overrides.
222///
223/// Build one with [`Config::builder`].
224#[non_exhaustive]
225pub struct Config {
226    /// Model identifier as understood by the endpoint, e.g.
227    /// `anthropic/claude-opus-4-8` or `openai/gpt-5` on OpenRouter.
228    pub model: String,
229
230    /// Base URL of the OpenAI-compatible endpoint (no trailing `/chat/...`).
231    pub base_url: String,
232
233    /// Explicit API key. If `None`, [`Self::api_key_env`] is consulted.
234    pub api_key: Option<String>,
235
236    /// Environment variable to read the API key from when [`Self::api_key`] is unset.
237    pub api_key_env: String,
238
239    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.8/§3.1
240    /// `core.api_key_cmd`, D6 row): a credential-helper command (pi§6
241    /// `!command` form). Consulted by [`Agent::new`] when [`Self::api_key`]
242    /// is unset: the command is run through the shell, its trimmed stdout
243    /// becomes the key, and a non-zero exit or empty output falls through to
244    /// [`Self::api_key_env`] rather than failing outright. `None` (the
245    /// default) means this is never consulted — byte-identical to today's
246    /// behavior. SECURITY: this is a command string, never a secret value —
247    /// [`Self::api_key`] itself must never be file-plaintext (§3.2 S13);
248    /// `api_key_cmd` is `[project-forbidden]` at every config-file layer
249    /// (§3.3), same trust boundary as `base_url`/`api_key_env`.
250    pub api_key_cmd: Option<String>,
251
252    /// System prompt prepended to every conversation.
253    pub system_prompt: String,
254
255    /// Optional sampling temperature.
256    pub temperature: Option<f32>,
257
258    /// Optional output token cap.
259    pub max_tokens: Option<u32>,
260
261    /// Maximum number of model/tool iterations per [`crate::Agent::send`] call.
262    pub max_iterations: usize,
263
264    /// Reasoning/effort level sent to the model (`reasoning_effort`).
265    pub effort: Option<String>,
266
267    /// Structured-output constraint (`response_format`), e.g. a json_schema.
268    pub response_format: Option<serde_json::Value>,
269
270    /// Extra request-body fields merged in (provider-native passthrough:
271    /// prompt-cache controls, provider-specific knobs).
272    pub extra_body: serde_json::Map<String, serde_json::Value>,
273
274    /// Optional cap on cumulative output tokens across one [`crate::Agent::send`]
275    /// loop; the loop stops once exceeded. Output tokens only; input/prompt
276    /// tokens are not counted, so this is not a cost cap.
277    pub max_total_output_tokens: Option<u64>,
278
279    /// Max bytes of a single tool result fed back into the conversation. Output
280    /// beyond this is truncated with a notice, so one runaway command (a huge
281    /// log, a binary dump) can't explode the context window. `None` disables the
282    /// cap. Defaults to 100 KB.
283    pub max_tool_output_bytes: Option<usize>,
284
285    /// Working directory tools operate within.
286    pub cwd: PathBuf,
287
288    /// Additional roots beyond `cwd` (the analog of `--add-dir` / multi-root):
289    /// searched for project-context files and available to tools.
290    pub additional_dirs: Vec<PathBuf>,
291
292    /// Whether to auto-load `CLAUDE.md` / `AGENTS.md` into the system prompt.
293    pub load_project_context: bool,
294
295    /// Filesystem confinement applied to write-capable tools.
296    pub sandbox: crate::tools::SandboxPolicy,
297
298    /// When the agent must seek approval before running a tool.
299    pub approval: ApprovalPolicy,
300
301    /// Tools that never require approval under [`ApprovalPolicy::OnRequest`].
302    pub auto_approved_tools: std::collections::HashSet<String>,
303
304    /// P4 (design §5.2 "P4": "deny-rule patterns generalizing
305    /// auto_approved_tools" — the S-sized generalization, NOT the full P5
306    /// `capabilities.permissions.rules` deny→ask→allow engine, §2.1
307    /// dependency 3's command-canonicalization prerequisite is P5-only).
308    /// Glob patterns (`*` wildcard, see [`glob_match`]) matched against a
309    /// tool's NAME — no argument/command-level matching. Any match forces
310    /// [`Config::needs_approval`] to `true` UNCONDITIONALLY, even under
311    /// [`ApprovalPolicy::Never`] — the entire point of a deny rule is a
312    /// hard floor `--yes`/`Never` can't bypass. Sourced from
313    /// `capabilities.permissions.rules.deny` (§3.1 module 11); empty by
314    /// default (today's behavior, byte-identical).
315    pub tool_deny_patterns: Vec<String>,
316
317    /// P4: the ALLOW-pattern generalization of [`Self::auto_approved_tools`]
318    /// — glob patterns matched against a tool's NAME, exempting a match from
319    /// approval under [`ApprovalPolicy::OnRequest`] exactly like an exact
320    /// `auto_approved_tools` entry does (never consulted under `Untrusted`,
321    /// same as `auto_approved_tools`). Sourced from
322    /// `capabilities.permissions.rules.allow`; empty by default.
323    pub tool_allow_patterns: Vec<String>,
324
325    /// Consulted when a tool call needs approval; `None` denies by default.
326    pub approval_handler: Option<ApprovalHandler>,
327
328    /// Runs before each tool executes; may block the call.
329    pub pre_tool_hook: Option<PreToolHook>,
330
331    /// Runs after each tool executes (observational).
332    pub post_tool_hook: Option<PostToolHook>,
333
334    /// Named prompt templates (skills / slash commands). A user message of the
335    /// form `/<name> <args>` is expanded to the template with `{args}` filled.
336    pub prompts: HashMap<String, String>,
337
338    /// If set, the conversation is compacted once it grows beyond this many
339    /// messages (older middle turns are summarized into one marker), keeping the
340    /// system prompt and the most recent turns.
341    pub compact_after_messages: Option<usize>,
342
343    /// Per-tool enable/disable + description overrides, keyed by tool name.
344    pub tool_overrides: HashMap<String, ToolOverride>,
345
346    /// How tools are advertised to the model (B6). Defaults to [`ToolAdvertising::Full`].
347    pub tool_advertising: ToolAdvertising,
348
349    /// Extra HTTP headers sent with every request (e.g. OpenRouter's
350    /// `HTTP-Referer` / `X-Title` attribution headers).
351    pub extra_headers: HashMap<String, String>,
352
353    /// Optional sink for streaming [`crate::AgentEvent`]s.
354    pub event_sink: Option<EventSink>,
355
356    /// Prompt-caching plan (B7). Defaults to [`CachePlan::Off`]; reduced mode
357    /// (`--reduced`, D5/D14) defaults it to [`CachePlan::ImportedPrefix`]
358    /// (wired at the CLI's reduced-mode assembly point, `crates/cli/src/main.rs`).
359    pub cache_plan: CachePlan,
360
361    /// Resolved optional reduction gates. These are kept separate from the
362    /// live policy because freshness probes and prepared summaries are
363    /// per-request data, not configuration.
364    pub reduction_policy: ReductionPolicySettings,
365
366    /// Whether the explicit reversible handoff projection is available.
367    /// This is separate from [`Self::reduction_policy`] because handoff is
368    /// an offline command over an existing sidecar, not a per-request
369    /// projection pass. Defaults to `true`; only an explicit composable
370    /// `capabilities.reduction.handoff = false` disables it.
371    pub handoff_enabled: bool,
372
373    /// Global tool-schema tier (TR-8/T5): how verbose ADVERTISED tool
374    /// schemas are. Defaults to [`crate::tools::SchemaTier::Full`] (today's
375    /// behavior — byte-identical schemas). A per-tool override in
376    /// [`ToolOverride::schema_tier`] wins over this for that tool. Tool
377    /// definitions are config, never session content, so this never affects
378    /// what's stored or exported — only what's advertised on the wire.
379    pub tool_schema_tier: crate::tools::SchemaTier,
380
381    /// UX-26 (B7-warn): whether [`crate::Agent`] emits
382    /// [`crate::AgentEvent::CacheWarning`] when a turn under
383    /// [`CachePlan::ImportedPrefix`] likely paid a full-price prompt-cache
384    /// miss despite reuse being expected (idle past the provider's TTL, or
385    /// usage reporting a near-zero cache-read ratio). Defaults to `true`
386    /// (on-brand token-economics feedback, on by default like the savings
387    /// figures `inspect stats` already surfaces); the CLI's
388    /// `--no-cache-warnings` flag / `cache_warnings = false` config / the
389    /// `SUPERCODE_CACHE_WARNINGS=0` env var turn it off. A no-op — never
390    /// checked — for any caller not using `CachePlan::ImportedPrefix`, so
391    /// this changes nothing under `CachePlan::Off` (today's default outside
392    /// reduced mode).
393    pub cache_warnings: bool,
394
395    /// P3 (COMPOSABLE-HARNESS-DESIGN.md §5.2 phase P3, mandatory risk-2
396    /// mitigation, §5.3 risk 2): the `[experimental] module_registry` flag.
397    /// `false` (the default) means [`crate::tools::ToolRegistry::from_config`]
398    /// returns EXACTLY [`crate::tools::ToolRegistry::with_builtins`] — the
399    /// runtime path is byte-for-byte today's behavior. Only when explicitly
400    /// turned on does [`Self::module_activation`] start shaping the
401    /// registry/prompt assembly.
402    pub module_registry: bool,
403
404    /// P3: the resolved §2 module-activation set (pure config → set,
405    /// computed by [`crate::configfile::resolve`]/[`crate::modules::ModuleActivation::from_harness`]
406    /// with no agent loop required). Only consulted when
407    /// [`Self::module_registry`] is `true`.
408    pub module_activation: crate::modules::ModuleActivation,
409
410    /// P3: the effective `[core.tools] enabled` list (§3.1) — which of the
411    /// core four (`read_file`/`bash`/`edit_file`/`write_file`, plus any
412    /// future core tool name) are present at all. Defaults to the §1.2
413    /// default-active four, matching [`crate::tools::ToolRegistry::with_builtins`]'s
414    /// unconditional registration. Only consulted when
415    /// [`Self::module_registry`] is `true`.
416    pub core_tools_enabled: Vec<String>,
417
418    /// P3: `[core.skills].enabled` (§1.4 obligation 4, D-7) — whether the
419    /// skills prompt section may appear at all. Still gated by D-7's read
420    /// pathway (`read_file` or `bash` present in [`Self::core_tools_enabled`])
421    /// at the assembly site. Only consulted when [`Self::module_registry`]
422    /// is `true`.
423    pub skills_enabled: bool,
424
425    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §3.1
426    /// `capabilities.model_catalog.small_model`, catalog §4a "Small/utility
427    /// model routing knob"): a cheaper/faster model id a caller (e.g. a
428    /// [`crate::reduce::summarize::SpanSummarizer`] implementation, or an
429    /// auto-title side-call) MAY use instead of [`Self::model`] for
430    /// low-stakes side-calls. `None` (the default) means every such
431    /// consumer falls back to the main model — the exact §2.1 D-9 fallback
432    /// behavior — since nothing in this crate resolves this field on its
433    /// own; it is a knob a caller reads, not a routing loop this crate runs.
434    pub small_model: Option<String>,
435
436    /// P4 (§3.1 `capabilities.model_catalog.fallback`, catalog §4a "Model
437    /// aliases + failure fallback chain"): an ordered list of full model
438    /// slugs a caller MAY retry against, in order, if [`Self::model`] fails.
439    /// Empty (the default) means no fallback chain is configured. Like
440    /// [`Self::small_model`], this is the resolved TABLE only — see
441    /// [`crate::model_catalog`]'s module doc for the scope boundary between
442    /// "a resolved list of slugs" (this field, S-sized) and an actual
443    /// retry/failover loop that consumes it (a separate, larger change).
444    pub model_fallback: Vec<String>,
445
446    /// P4b (COMPOSABLE-HARNESS-DESIGN.md design doc S5.2 "P4", S1.4/S3.1
447    /// `core.env_context`, catalog S4a "Environment context block
448    /// injection"): when `true`, `Agent::with_parts` appends a short
449    /// `# Environment` block (cwd, platform, date, best-effort git branch)
450    /// to the system prompt, alongside `Self::load_project_context`'s
451    /// instruction files. `false` (the default) is byte-identical to
452    /// today's behavior.
453    pub env_context: bool,
454
455    /// P4b (S1.4/S3.1 `core.project_root_markers`, catalog:232): filenames
456    /// that mark a directory as the project root for `Self::env_context`'s
457    /// git-status probe. Defaults to `[".git"]`.
458    pub project_root_markers: Vec<String>,
459
460    /// P4b (S1.4/S3.1 `core.project_doc_max_bytes`, cx2 "project_doc_max_bytes"
461    /// analog, S5.2 P4 "instruction-walk nuances"): a hygiene cap on the
462    /// TOTAL bytes of instruction-file content (`Self::load_project_context`'s
463    /// global + project tiers combined) appended to the system prompt. `None`
464    /// (the default) is uncapped -- byte-identical to today's behavior; only
465    /// an explicit `Some(n)` truncates the assembled block (with a trailing
466    /// notice), mirroring `Self::max_tool_output_bytes`'s cap-with-notice
467    /// shape.
468    pub project_doc_max_bytes: Option<usize>,
469
470    /// P4b (S1.4/S3.1 `core.instruction_imports`, catalog:85): when `true`,
471    /// an instruction file may reference another file via an `@relative/path`
472    /// token (CC's import syntax) -- the referenced file's contents are
473    /// inlined in its place, resolved relative to the IMPORTING file's own
474    /// directory, to a max depth of 4 (CC's own default) to bound cycles.
475    /// `false` (the default) leaves `@` tokens as plain literal text --
476    /// byte-identical to today's behavior.
477    pub instruction_imports: bool,
478
479    /// P4b (S1.1/S3.1 `core.retry`, pi3 shape): whether a transient
480    /// (connection failure / 5xx) provider error is retried at all. This
481    /// EXTENDS a pre-existing, always-on transport-layer mechanism
482    /// (`provider::OpenAiProvider`'s internal `HttpOptions` retry — 2
483    /// attempts / 500ms base backoff, hardcoded, not previously
484    /// config-file-settable) rather than adding a second one: `true` (the
485    /// default, matching today's always-on behavior byte-for-byte when
486    /// `Self::retry_max_retries`/`Self::retry_base_delay_ms` are also both
487    /// unset) keeps retrying; an explicit `false` is a NEW capability —
488    /// disabling the transport retry entirely.
489    pub retry_enabled: bool,
490    /// Override the transport retry's attempt count. `None` (the default)
491    /// keeps the pre-existing built-in default (2).
492    pub retry_max_retries: Option<u32>,
493    /// Override the transport retry's base backoff delay in milliseconds
494    /// (doubles per attempt). `None` (the default) keeps the pre-existing
495    /// built-in default (500ms).
496    pub retry_base_delay_ms: Option<u64>,
497
498    /// P4b (S1.5/S3.1 `core.compaction.reserve_tokens`, pi2 shape): once
499    /// set, `Agent::maybe_compact` ALSO triggers when the estimated token
500    /// size of the live history is within `reserve_tokens` of the model's
501    /// context window -- in addition to (not instead of)
502    /// `Self::compact_after_messages`'s message-count trigger. `None` (the
503    /// default) leaves the pressure trigger off -- byte-identical to today's
504    /// message-count-only behavior.
505    pub compaction_reserve_tokens: Option<u64>,
506    /// P4b (S1.5/S3.1 `core.compaction.keep_recent_tokens`): when the
507    /// PRESSURE trigger (not the message-count one) fires, how many of the
508    /// most recent tokens (estimated) to keep verbatim instead of a fixed
509    /// message count. Only consulted when `Self::compaction_reserve_tokens`
510    /// is `Some` and the pressure trigger is what fired.
511    pub compaction_keep_recent_tokens: Option<u64>,
512    /// P4b (S1.5/S3.1 `core.compaction.focus_instructions`, catalog D2 "no
513    /// instruction steering" gap): free text appended to the synthetic
514    /// compaction marker message every time compaction fires (either
515    /// trigger), steering the model on what to keep focusing on
516    /// post-compaction (CC's manual-compact `/compact <focus>` analog).
517    /// `None` (the default) leaves the marker text byte-identical to
518    /// today's.
519    pub compaction_focus_instructions: Option<String>,
520
521    /// P4b (S1.6/S3.1 `core.session.auto_title`, catalog:150, D-9): whether
522    /// `crate::session_title::auto_title` may be invoked at all by a caller
523    /// (the caller still supplies the `SessionTitler` side-call itself --
524    /// this is only the gate, mirroring `Self::small_model`'s "a knob a
525    /// caller reads" framing). `false` (the default): callers should treat
526    /// auto-title as off.
527    pub auto_title: bool,
528
529    /// P4b (S1.7/S3.1 `core.steering`, pi3 semantics): how queued mid-turn
530    /// steering messages (`Agent::queue_steer`) are drained -- `All`
531    /// delivers every queued message at once, `OneAtATime` (the default)
532    /// delivers one per drain point.
533    pub steering_mode: SteeringMode,
534    /// P4b (S1.7/S3.1 `core.steering.follow_up_mode`): how queued follow-up
535    /// messages (`Agent::queue_follow_up`) are drained once the loop is
536    /// otherwise idle (no more tool calls pending).
537    pub follow_up_mode: SteeringMode,
538
539    /// P4b (S1.9/S3.1 `[core] stop_gate`, D3 "stop/completion gating", CC
540    /// Stop-hook semantics cc3): consulted exactly once per `run_loop`
541    /// iteration that would otherwise return a final answer (no more tool
542    /// calls pending, and the follow-up queue is empty). Receives the
543    /// would-be-final assistant message; `Some(reason)` VETOES termination
544    /// -- `reason` is injected as a new user message and the loop continues
545    /// (still bounded by `Self::max_iterations`); `None` allows the stop.
546    /// Code-only, like `Self::pre_tool_hook`/`Self::post_tool_hook` --
547    /// the CLI's declarative `[hooks] stop = "cmd"` form (module 17)
548    /// populates this SAME single slot rather than adding a second call
549    /// site, so the two can never double-fire (S2 module 17's "hooks layer
550    /// on core's gate" note). `None` (the default) is byte-identical to
551    /// today's behavior.
552    pub stop_gate: Option<StopGateHook>,
553
554    /// P4c (COMPOSABLE-HARNESS-DESIGN.md S1.2/S3.1 `core.tools.read_file
555    /// multimodal`, catalog S4a "Multimodal read (image passthrough on
556    /// `read_file`)"): when `true`, `read_file` returns a recognized image
557    /// file (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`/`.bmp`) as a model-visible
558    /// image content block instead of decoding it as (garbled) UTF-8 text.
559    /// `false` (the default) is byte-identical to today's behavior.
560    pub read_file_multimodal: bool,
561
562    /// P4c (S1.2/S3.1 `core.tools.edit_file.require_read_before_edit`,
563    /// UNIQUE CC row, catalog:32): when `true`, `edit_file` refuses unless
564    /// the target path was read (via `read_file`) earlier in this same
565    /// conversation -- tracked in `ToolContext`. `false` (the default) is
566    /// byte-identical to today's behavior.
567    pub edit_file_require_read_before_edit: bool,
568
569    /// P4c (S1.2/S3.1 `core.tools.edit_file.notebook_aware`, UNIQUE CC row
570    /// "NotebookEdit", catalog:40): when `true`, `edit_file` additionally
571    /// accepts Jupyter cell replace/insert/delete operations against a
572    /// `.ipynb` target (see `tools::builtins::EditFileTool`'s cell-op args)
573    /// instead of only the exact-string replace it always supports. `false`
574    /// (the default) is byte-identical to today's behavior.
575    pub edit_file_notebook_aware: bool,
576
577    /// P4c (S1.2/S3.1 `core.shell_env_snapshot`, SPLIT CC+CX row,
578    /// catalog:338): when `true`, `Agent::new`/`with_parts` captures the
579    /// user's interactive login-shell environment ONCE at construction
580    /// (`$SHELL -lc env`, best-effort) and every `bash` call inherits it
581    /// directly instead of needing to re-source shell rc files per call.
582    /// `false` (the default) is byte-identical to today's behavior -- no
583    /// snapshot is captured, and `bash` sees only the ambient process
584    /// environment, exactly as before this landed.
585    pub shell_env_snapshot: bool,
586
587    /// P4c (S5.2 P4 "doom-loop breaker", oc `doom_loop` UNIQUE row,
588    /// catalog D3): when `Some(n)` with `n >= 2`, a tool call whose name AND
589    /// arguments are byte-identical to the previous `n - 1` consecutive
590    /// calls is refused (fed back to the model as an error) instead of
591    /// executed -- the counter resets the moment a call differs. `None`
592    /// (the default) is byte-identical to today's behavior: no repetition
593    /// tracking, no call is ever refused on this basis.
594    pub doom_loop_threshold: Option<u32>,
595
596    /// P4c (S1.4/S3.1 `core.nested_instructions`, catalog:84, deferred from
597    /// P4b): when `true`, a `read_file`/`edit_file` call that touches a path
598    /// inside a subdirectory carrying its OWN `CLAUDE.md`/`AGENTS.md` (a
599    /// directory other than `Config.cwd` itself, which
600    /// `Self::load_project_context` already loads once at session start)
601    /// appends that subdirectory's instructions to the tool's OWN result the
602    /// FIRST time a path under it is touched this conversation (deduped
603    /// thereafter -- tracked in `ToolContext`, mirrors CC/OC's "auto-attach
604    /// on read, deduped" semantics, catalog:84). Reuses the same
605    /// canonicalize+containment safety check P4b's `@`-import expansion
606    /// uses (`agent::import_target_is_contained`) so a symlink cannot walk
607    /// the injection outside `Config.cwd`. `false` (the default) is
608    /// byte-identical to today's behavior.
609    pub nested_instructions: bool,
610
611    /// P4c (S1.10/S3.1 `core.model_switch.allow_switch`, D9 row, dep 8):
612    /// gates whether `Agent::switch_model` does more than the pre-existing
613    /// `Agent::set_model` mechanics (design's "UX-30 dev/02" -- swap
614    /// `Config.model` for the next request, nothing else touched). `false`
615    /// (the default) makes `switch_model` byte-identical to calling
616    /// `set_model` directly: no persisted `model_change` record, no
617    /// reasoning-artifact filtering. `true` additionally (1) appends a
618    /// typed `model_change::ModelChangeRecord` to
619    /// `Agent::model_change_records`, and (2) runs
620    /// `reduce::rehydrate::filter_reasoning_artifacts` over `Agent::history`
621    /// so model-A's reasoning/thinking artifacts (`ChatMessage::metadata`
622    /// keys and any `content_parts` reasoning blocks) never reach
623    /// model-B's context (S1.13, dep 8).
624    pub model_switch_allow_switch: bool,
625
626    /// P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
627    /// context-injection blocks"): the master gate for
628    /// [`Self::context_injection_blocks`] -- when `false` (the default),
629    /// `Agent::with_parts` never appends any of them, byte-identical to
630    /// today's behavior. `true` splices in whatever named blocks are set,
631    /// at the same assembly site P4b's `env_context` block uses, right
632    /// after it.
633    pub context_injections: bool,
634    /// P4e: named ambient context blocks a caller/embedder populates
635    /// programmatically (mirrors `Self::prompts`/`Self::stop_gate`'s
636    /// code-extensible shape) -- there is no `[core.context_injections.*]`
637    /// FILE table because the §3.1 schema's `core.context_injections` key
638    /// is already a scalar boolean gate, and TOML forbids a key being both
639    /// scalar and table (the same S-fix documented on
640    /// `[core.model_switch]`). Consulted only when
641    /// [`Self::context_injections`] is `true`; empty (the default) is a
642    /// no-op even then. Each block is appended verbatim as `\n\n# {name}\n{content}`,
643    /// in list order.
644    pub context_injection_blocks: Vec<ContextInjectionBlock>,
645
646    /// P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
647    /// yet"): the master on/off switch for ALL auto-compaction
648    /// (`Agent::maybe_compact`), composing with -- not replacing -- the
649    /// existing `Self::compact_after_messages`/`Self::compaction_reserve_tokens`/
650    /// `Self::compaction_keep_recent_tokens` triggers: `false` disables
651    /// every trigger unconditionally; `true` (the default, matching
652    /// today's behavior, where nothing has ever gated compaction) changes
653    /// nothing -- whichever triggers are configured still fire exactly as
654    /// before.
655    pub compaction_enabled: bool,
656
657    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
658    /// sibling calls run concurrently"): when `true` and an assistant turn
659    /// requests more than one tool call, `Agent::run_loop` runs their
660    /// `Tool::execute` futures CONCURRENTLY via `Self::run_tools_concurrently`
661    /// instead of one at a time -- see that method's doc comment for
662    /// exactly which part of dispatch stays strictly sequential (approval /
663    /// doom-loop / pre-tool-hook checks, and every `record`/`history`
664    /// append, which the lossless sidecar's append-order invariant, S1.13,
665    /// requires to stay deterministic). `false` (the default) is
666    /// byte-identical to today's sequential-await-per-call loop.
667    pub parallel_tool_calls: bool,
668
669    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331 "Git branch/
670    /// sha captured … closes the loop" -- the WRITE half; supercode already
671    /// preserves a foreign session's own `gitBranch`-shaped fields
672    /// verbatim on IMPORT via `Session::raw`'s byte-for-byte capture).
673    /// When `true`, `Agent::with_parts` captures a
674    /// `git_metadata::GitMetadataRecord` (best-effort branch/sha/dirty,
675    /// like `Self::env_context`'s git probe) once at construction, readable
676    /// via `Agent::git_metadata` and persistable via
677    /// `Agent::save_git_metadata`. `false` (the default) is byte-identical
678    /// to today's behavior: no capture, `Agent::git_metadata()` is always
679    /// `None`.
680    pub session_git_metadata: bool,
681
682    /// P4e (§1.6/§3.1 `core.session.dir`): overrides the session store's
683    /// root directory. A caller-read knob (like `Self::small_model`) --
684    /// the CLI's `session_store()` (main.rs) is the consumer. `None` (the
685    /// default) leaves the CLI's own default (`$SUPERCODE_HOME/sessions`)
686    /// untouched.
687    pub session_dir: Option<String>,
688    /// P4e (§1.6/§3.1 `core.session.persist`, D5 row): whether a caller
689    /// should persist this session to the store at all. A caller-read gate
690    /// only -- `Agent`/`Config` never call `SessionStore` directly (no
691    /// `SessionStore` handle lives on `Config`); a caller checks this
692    /// field directly before calling `store.save(...)`, the same
693    /// "mechanism vs. gate" split `Self::auto_title` established. `true`
694    /// (the default) matches today's behavior: every caller that already
695    /// calls `store.save(...)` keeps doing so unconditionally.
696    pub session_persist: bool,
697    /// P4e (§1.6/§3.1 `core.session.name`): an explicit session name a
698    /// caller should use instead of auto-minting one (the CLI's
699    /// `mint_session_name`). A caller-read knob, same posture as
700    /// `Self::session_dir`. `None` (the default) leaves auto-naming
701    /// untouched.
702    pub session_name: Option<String>,
703    /// P4e (§1.6/§3.1 `core.session.retention_days`): the archive-pruning
704    /// window `store::SessionStore::prune_expired` consults. `None` (the
705    /// default) means "never prune" -- byte-identical to today's behavior
706    /// (nothing ever prunes automatically).
707    pub session_retention_days: Option<u32>,
708    /// P4e (§1.6/§3.1 `core.session.export_format`, catalog:283 "transcript
709    /// export for humans"): `text` | `html`, consumed by
710    /// `human_export::render_transcript`. Defaults to
711    /// [`crate::human_export::HumanExportFormat::Text`].
712    pub session_export_format: crate::human_export::HumanExportFormat,
713
714    /// P5-1 (§3.1 `capabilities.permissions.enabled`, module 10/11
715    /// activation): the master gate for `crate::permissions` — when `false`
716    /// (the default), `Agent::prepare_tool_call`'s tool-dispatch gate uses
717    /// EXACTLY the pre-P5-1 [`Self::needs_approval`] path, byte-for-byte —
718    /// no behavior change. `true` switches the gate to the richer
719    /// canonicalized-command-aware [`crate::permissions::rules`] engine
720    /// (deny→ask→allow first-match, C5), consulting
721    /// [`Self::permissions_ask_patterns`] (together with the pre-existing
722    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] as the
723    /// engine's deny/allow tiers) and [`Self::permissions_protected_paths`].
724    pub permissions_enabled: bool,
725
726    /// P5-1 (§3.1 `capabilities.permissions.rules.ask`, module 11): the
727    /// engine's `ask` tier — the sibling of the pre-existing
728    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] (P4),
729    /// which become the engine's `deny`/`allow` tiers respectively when
730    /// [`Self::permissions_enabled`] is on (see
731    /// `crate::permissions::rules::RuleSet`). Empty by default. Only
732    /// consulted when [`Self::permissions_enabled`] is `true`.
733    pub permissions_ask_patterns: Vec<String>,
734
735    /// P5-1 (§3.1 `capabilities.permissions.protected_paths.paths`, module
736    /// 13): glob patterns that are an unconditional DENY floor for both
737    /// read and write access (cc§4 "never auto-approved… `.git/**`,
738    /// `.env*`, …"), expanded via
739    /// [`crate::permissions::rules::protected_path_deny_rules`] into the
740    /// engine's `deny` tier. Empty by default. Only consulted when
741    /// [`Self::permissions_enabled`] is `true`.
742    ///
743    /// **Honesty note on coverage (F4, Fable-5 adversarial review):** at
744    /// the rule-engine layer this floor is enforced for (a) `read_file`/
745    /// `write_file`/`edit_file`-shaped path calls, (b) a `bash`/`shell`
746    /// command's direct output/input redirect targets (`>`, `>>`, `&>`,
747    /// `>|`, `&>>`, `<`), (c) `apply_patch`'s target path(s), and (d) a
748    /// best-effort set of known argv-writers (`tee`, `dd of=`, `cp`/`mv`/
749    /// `install`, `sed -i`, `truncate`, `ln`) — see
750    /// `crate::permissions::canon::known_writer_targets`'s doc comment for
751    /// that heuristic's named gaps. A write this rule layer genuinely
752    /// cannot statically resolve (an opaque wrapper — `eval`, `sh -c`, …
753    /// — or a dynamic `$VAR`/`` `cmd` `` target) is forced to at least
754    /// `Ask`, never silently `Allow`. What this layer does NOT provide is
755    /// COMPLETE OS-level write confinement of arbitrary bash — that is
756    /// `capabilities.permissions.sandbox`'s job (P5 module 10, a later
757    /// unit), not this one's.
758    pub permissions_protected_paths: Vec<String>,
759
760    /// P5-1 (§3.1 `capabilities.permissions.sandbox.network.*`, module 12
761    /// carry-forward): the domain allow/deny policy `crate::tools::WebFetchTool`/
762    /// `WebSearchTool` enforce via `crate::tools::ToolContext::check_network`
763    /// — the enforcement POINT already existed (P4c); this is its real
764    /// config source (`crate::configfile::materialize_config`). `None` (the
765    /// default) is byte-identical to today's behavior: no policy is
766    /// enforced, exactly the honest gap `NetworkPolicy`'s own doc comment
767    /// (`crate::tools`) already names.
768    pub network_policy: Option<crate::tools::NetworkPolicy>,
769
770    /// P5-10 (§3.1 `capabilities.permissions.sandbox.enabled`, module 12):
771    /// whether the OS-level backstop (Landlock on Linux, seatbelt on
772    /// macOS) is engaged for the `bash`/`shell` subprocess. `None` (the
773    /// default — unset by the bare `sandbox = "<tier>"` shorthand, or a
774    /// CLI `--sandbox` flag, neither of which touch this table key) keeps
775    /// the PRE-P5-10 trigger byte-identical: `crate::sandbox::
776    /// os_sandbox_active` falls back to "confine whenever the tier isn't
777    /// `DangerFullAccess`", exactly what the macOS seatbelt path already
778    /// did off `Self::sandbox` alone. `Some(false)` (the table form's
779    /// explicit opt-out — `cc-parity`'s posture) turns the OS backstop off
780    /// even for a confining tier; `Some(true)` forces it on.
781    pub sandbox_os_enabled: Option<bool>,
782
783    /// P5-10 (§3.1 `capabilities.permissions.sandbox.escalation`, module
784    /// 12): what happens when a confining fs tier is requested but this
785    /// platform/kernel can't enforce it — see
786    /// [`crate::sandbox::SandboxEscalation`]. Defaults to `Deny`
787    /// (fail-closed), matching `capabilities.permissions.sandbox`'s own
788    /// `escalation = "deny"` config default.
789    pub sandbox_escalation: crate::sandbox::SandboxEscalation,
790
791    /// P5-10 (§3.1 `capabilities.permissions.sandbox.env_policy`, module
792    /// 12): child-process environment sanitization for the spawned
793    /// `bash`/`shell` subprocess — see
794    /// [`crate::sandbox::SandboxEnvPolicy`]. Defaults to `Inherit`
795    /// (byte-identical to pre-P5-10 behavior: the full environment passes
796    /// through unchanged).
797    pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
798
799    /// P5-3 (§3.1 `capabilities.subagents.enabled`, module 9 activation):
800    /// the master gate for the `spawn_subagent`/`subagent_status` agent
801    /// the master gate for the `spawn_subagent`/`subagent_status` agent
802    /// intrinsics — when `false` (the default), `Agent::tool_schemas` never
803    /// advertises them and `Agent::run_tool`'s interception is a pure
804    /// pass-through to the pre-P5-3 dispatch, byte-for-byte unchanged.
805    pub subagents_enabled: bool,
806    /// P5-3 (§3.1 `capabilities.subagents.max_depth`, resource bound): the
807    /// maximum spawn-tree depth — a depth-`max_depth` agent may not spawn
808    /// (its child would land at `max_depth + 1`). Only consulted when
809    /// [`Self::subagents_enabled`] is `true`.
810    pub subagents_max_depth: usize,
811    /// P5-3 (resource bound, NOT in the §3.1 illustrative schema snippet —
812    /// added per the build brief's explicit "max concurrent subagents…
813    /// cap, fail-closed... configurable"): the maximum number of subagents
814    /// in flight anywhere in one spawn tree at once (root-to-leaf, shared
815    /// via [`crate::agent::Agent`]'s concurrency gauge). Only consulted
816    /// when [`Self::subagents_enabled`] is `true`.
817    pub subagents_max_concurrent: usize,
818    /// P5-3 (§3.1 `capabilities.subagents.background`): whether
819    /// `spawn_subagent`'s `background: true` argument is honored at all —
820    /// `false` (the default) refuses every background spawn regardless of
821    /// [`Self::subagents_background_prompts`].
822    pub subagents_background: bool,
823    /// P5-3 (§2.2 C6, §3.1 `capabilities.subagents.background_prompts`):
824    /// the auto-policy a background child's tool approvals route through.
825    /// `None` (the default) means a background spawn is refused
826    /// (`Error::SubagentBackgroundPolicyMissing`) — a detached child must
827    /// never reach an interactive prompt it can't answer.
828    pub subagents_background_prompts: Option<crate::subagents::BackgroundPromptsPolicy>,
829    /// Claude Code emulation: advertise and accept its `Agent` tool name and
830    /// argument vocabulary in addition to Supercode's native
831    /// `spawn_subagent` intrinsic. Default `false`; enabled only for an
832    /// explicitly imported Claude continuation.
833    pub subagents_claude_agent_alias: bool,
834    /// Claude Code resume compatibility for the scheduler-shaped
835    /// `CronCreate`/`CronDelete`/`CronList`/`ScheduleWakeup` intrinsics.
836    /// The imported manifest is always paused and these tools only mutate
837    /// that inert state; no timer is started. Default `false` so ordinary
838    /// agents do not gain a harness-specific tool surface.
839    pub claude_runtime_tools_enabled: bool,
840    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`, D3 "named-defs"):
841    /// named subagent types, keyed by the name the model passes as
842    /// `spawn_subagent`'s `agent_type` argument.
843    pub subagents_definitions: HashMap<String, crate::subagents::NamedAgentDefinition>,
844    /// P5-3 (runtime-only, NEVER set from a config file — only
845    /// `Agent::run_spawn_subagent` sets it on a freshly-built CHILD
846    /// `Config` before constructing that child): how deep in the spawn
847    /// tree the agent built from this `Config` is. `0` is a top-level
848    /// agent; a config file / [`ConfigBuilder`] caller that never spawns
849    /// leaves this at its `0` default.
850    pub subagent_depth: usize,
851
852    /// P5-4 (§3.1 `capabilities.tui.enabled`, module 30 activation, §1.9
853    /// recorded deviation): the master gate for the full-screen TUI —
854    /// when `false` (the default), `crates/cli`'s `chat()` runs the
855    /// pre-P5-4 rustyline REPL loop byte-for-byte, and every P5-4 seam
856    /// below (`Agent::set_permissions_approval_handler`/
857    /// `Agent::set_child_approval_handler_factory`/
858    /// `crate::mcp::McpClient::set_elicitation_handler`) is simply never
859    /// invoked with a TUI-backed implementation. `crates/cli`'s TUI runner
860    /// additionally requires stdin/stdout/stderr all be a real tty before
861    /// activating even when this is `true` — see that crate's
862    /// `tui::should_activate` doc comment.
863    pub tui_enabled: bool,
864    /// P5-4 (§3.1 `capabilities.tui.theme`): `"dark"` | `"light"` — which
865    /// built-in [`crate::tui::Theme`] the renderer starts with. Unknown or
866    /// unset values fall back to `"dark"` (`crate::tui::Theme::default()`).
867    pub tui_theme: String,
868    /// P5-4 (§3.1 `capabilities.tui.vim_mode`, D8 "vim"): whether the
869    /// input buffer starts in vim-style modal editing (normal/insert)
870    /// rather than plain single-mode editing. See
871    /// [`crate::tui::InputMode`]'s doc comment for the (deliberately
872    /// basic — hjkl/i/a/o/dd/x) scope of what's implemented.
873    pub tui_vim_mode: bool,
874    /// P5-4 (§3.1 `capabilities.tui.keymap.<action> = "<key>"`,
875    /// "configurable keybindings"): per-action key overrides layered on
876    /// top of [`crate::tui::Keymap::default()`] — see that type's doc
877    /// comment for the action names and key-spec syntax understood.
878    pub tui_keymap: HashMap<String, String>,
879
880    /// P5-5 (§3.1 `capabilities.session_tree.enabled`, design §2 module 21
881    /// activation): the master gate for the native in-place session tree
882    /// (`crate::session_tree`) — a pure "does the harness advertise/prefer
883    /// tree-mode session semantics" signal for a caller (CLI/TUI) to consult.
884    /// `false` (the default, matching every `HarnessConfig` that never sets
885    /// this table) changes nothing about [`crate::session_tree::SessionTree`]
886    /// itself, which has no runtime dependency on this flag (a caller can
887    /// always construct/use one directly, exactly like
888    /// [`crate::store::SessionStore::fork`] isn't gated on any capability
889    /// either) — this field exists purely so a future integration point has
890    /// a resolved config signal to read, matching every other P5 module's
891    /// "carried on `Config`, pure config → set" convention.
892    pub session_tree_enabled: bool,
893    /// P5-5 (§3.1 `capabilities.session_tree.branch_summaries`, module 21
894    /// "branch summaries"): whether a caller wiring
895    /// [`crate::session_tree::SessionTree::splice_for_linear_export`] into a
896    /// C7 linear-export path should generate/attach summaries for off-path
897    /// branches at all, vs. leaving them unsummarized (still fully present
898    /// in the sidecar either way — this only controls the human-readable
899    /// digest, never the underlying lossless data). Defaults `true` (the
900    /// §3.1 schema's own default) when [`Self::session_tree_enabled`] is
901    /// `true` and this key is unset.
902    pub session_tree_branch_summaries: bool,
903    /// P5-5 (§3.1 `capabilities.session_tree.labels`, module 21 "entry
904    /// labels"): whether a caller's UI/CLI surface should expose
905    /// [`crate::session_tree::SessionTree::label`]/`clear_label` at all.
906    /// Defaults `true` (the §3.1 schema's own default) when
907    /// [`Self::session_tree_enabled`] is `true` and this key is unset. Like
908    /// [`Self::session_tree_branch_summaries`], this is advisory — the
909    /// underlying `SessionTree` API always supports labeling regardless.
910    pub session_tree_labels: bool,
911    /// P5-6 (§3.1 `capabilities.tools_background.enabled`, module 4
912    /// activation): the master gate for the `background_exec`/
913    /// `background_status`/`background_list`/`background_kill` agent
914    /// intrinsics — when `false` (the default), `Agent::tool_schemas`
915    /// never advertises them and `Agent::prepare_tool_call`'s interception
916    /// is a pure pass-through to the pre-P5-6 dispatch, byte-for-byte
917    /// unchanged (a hallucinated call falls through to the ordinary
918    /// unknown-tool error, exactly like `spawn_subagent`'s own disabled
919    /// posture).
920    pub tools_background_enabled: bool,
921    /// P5-6 (resource bound, NOT in the §3.1 illustrative schema snippet —
922    /// added per the build brief's explicit "max-concurrent cap,
923    /// fail-closed", mirroring [`Self::subagents_max_concurrent`]'s own
924    /// precedent): the maximum number of background jobs this agent may
925    /// have running at once. Only consulted when
926    /// [`Self::tools_background_enabled`] is `true`.
927    pub tools_background_max_concurrent: usize,
928    /// P5-6 (resource bound, "must not OOM" — mirrors
929    /// `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s hardening-cap precedent): the
930    /// maximum number of bytes of combined stdout/stderr retained per
931    /// background job — output beyond this is truncated-with-marker, never
932    /// buffered further (`crate::background::CapturedOutput::append`).
933    /// Only consulted when [`Self::tools_background_enabled`] is `true`.
934    pub tools_background_max_output_bytes: usize,
935    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`, module 20 activation):
936    /// the master gate for file checkpointing — when `false` (the
937    /// default), `crate::agent::build_tool_context` never touches disk for
938    /// this at all: no `crate::checkpoint::CheckpointStore` is opened, no
939    /// shadow directory is created, `ToolContext::write_observer` stays
940    /// `None`, and every write-tool call site's observer branch is a
941    /// pure no-op — byte-identical to before this module existed. See
942    /// `crate::checkpoint`'s module doc comment for the full design.
943    pub checkpoint_enabled: bool,
944    /// P5-9 (bounded-disk requirement, NOT in the §3.1 illustrative schema
945    /// snippet — added per the build brief's explicit "bounded... no
946    /// unbounded disk growth", mirroring [`Self::tools_background_max_concurrent`]'s
947    /// own precedent): the maximum number of checkpoints retained per
948    /// project before the oldest are pruned. Only consulted when
949    /// [`Self::checkpoint_enabled`] is `true`.
950    pub checkpoint_retain: usize,
951    /// P5-9 (embedder/test override, NOT a `[capabilities.checkpoint]`
952    /// schema key — this is a Rust-only knob, the same class as
953    /// [`Self::pre_tool_hook`]/[`Self::post_tool_hook`]): where the shadow
954    /// store lives. `None` (the default) means
955    /// `crate::checkpoint::observer_for_config` derives the location from
956    /// `crate::agent::global_instructions_dir()` + a hash of [`Self::cwd`]
957    /// (mirroring the CLI's own `cwd_tag` precedent) — set this to make the
958    /// location hermetic/deterministic (tests; embedders that want a
959    /// specific on-disk layout) without touching process-global env vars.
960    pub checkpoint_dir: Option<PathBuf>,
961    /// P5-11 (§3.1 `capabilities.lsp.enabled`, module 28 activation): the
962    /// master gate for LSP server lifecycle + edit-path diagnostics (D1).
963    /// `false` (the default) means `crate::agent::build_tool_context` never
964    /// touches `crate::lsp::manager_for_config` at all — no child process
965    /// is ever spawned, `ToolContext::write_observer`'s chain never gains
966    /// an LSP entry — byte-identical to before this module existed. See
967    /// `crate::lsp`'s module doc comment for the accepted gaps (no
968    /// auto-provisioned server fleet, no symbol-indexing query tool).
969    pub lsp_enabled: bool,
970    /// P5-11 (`capabilities.lsp.servers.<name>`): the configured language
971    /// servers, in alphabetical order by server name (a TOML table has no
972    /// inherent ordering — `configfile::materialize_config` sorts
973    /// explicitly for reproducibility) — first extension match wins. Only
974    /// consulted when [`Self::lsp_enabled`] is `true`. An empty `Vec` with
975    /// `lsp_enabled = true` is legal but warns once
976    /// (`crate::lsp::manager_for_config`) — very likely a config mistake.
977    pub lsp_servers: Vec<(String, crate::lsp::LspServerSpec)>,
978    /// P5-11 (bounded-context requirement, NOT in the §3.1 illustrative
979    /// schema snippet — added per the build brief's explicit "a flood
980    /// mustn't blow context", mirroring [`Self::tools_background_max_output_bytes`]'s
981    /// own precedent): the maximum number of diagnostics rendered into a
982    /// single tool result. Only consulted when [`Self::lsp_enabled`] is
983    /// `true`.
984    pub lsp_max_diagnostics: usize,
985    /// P5-11 (bounded-latency requirement): how long to wait for a
986    /// configured server to publish diagnostics after a
987    /// `didOpen`/`didChange` before giving up gracefully. Only consulted
988    /// when [`Self::lsp_enabled`] is `true`.
989    pub lsp_timeout_secs: u64,
990    /// P5-11 (§3.1 `capabilities.formatters.enabled`, module 29
991    /// activation): the master gate for format-on-write. `false` (the
992    /// default) means the shared D-5 write-observer chain never gains a
993    /// `crate::formatters::FormatObserver` entry — byte-identical to
994    /// before this module existed.
995    pub formatters_enabled: bool,
996    /// P5-11 (`capabilities.formatters.<name>`): the configured formatter
997    /// commands, in alphabetical order by formatter name (same "TOML has
998    /// no inherent ordering" rationale as [`Self::lsp_servers`]) — first
999    /// extension match wins. Only consulted when
1000    /// [`Self::formatters_enabled`] is `true`.
1001    pub formatters: Vec<(String, crate::formatters::FormatterSpec)>,
1002    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10): whether a
1003    /// formatter's rewrite is diffed back into the calling tool's result
1004    /// so the model's file-memory stays truthful (design line 534, "must
1005    /// diff-back into the result"). `true` is the C10-SAFE default; `false`
1006    /// still runs the formatter but withholds the annotation — legal, but
1007    /// the model then has a stale belief about the file's exact bytes
1008    /// until it re-reads it.
1009    pub formatters_diff_back: bool,
1010    /// P5-11 (bounded-latency requirement, "a hanging formatter can't hang
1011    /// the loop — timeout + kill like hooks"): how long a single formatter
1012    /// invocation may run before it's treated as failed (the file is left
1013    /// untouched). Only consulted when [`Self::formatters_enabled`] is
1014    /// `true`.
1015    pub formatters_timeout_secs: u64,
1016    /// P5-12 (§2 module 14 `trust`, D-10): the master gate for the
1017    /// project/workspace trust concept — `false` (the default) means
1018    /// [`Self::trust_default`] is never consulted and [`crate::plugins`]
1019    /// treats every plugin as untrusted (see
1020    /// [`crate::plugins::is_trusted`]'s doc comment). `[capabilities.trust]`
1021    /// is project-forbidden (`configfile::PROJECT_FORBIDDEN_CAPABILITY_TABLES`
1022    /// / `userconfig`'s own copy): only the user/global layer — or a
1023    /// preset extended from it — may ever set this, exactly like
1024    /// `hooks`/`plugins`/`server` (a project asserting its OWN trust would
1025    /// defeat the entire point of the gate).
1026    pub trust_enabled: bool,
1027    /// P5-12 (`capabilities.trust.default`): the workspace-trust decision —
1028    /// see [`crate::plugins::TrustDecision`]'s doc comment for why, absent a
1029    /// wired interactive upgrade flow, only [`crate::plugins::TrustDecision::Always`]
1030    /// actually unlocks plugin loading in this build (an honest,
1031    /// documented gap — not a silent no-op: `ask`/`never` both cleanly
1032    /// refuse, they don't pretend to prompt). Only consulted when
1033    /// [`Self::trust_enabled`] is `true`.
1034    pub trust_default: crate::plugins::TrustDecision,
1035    /// P5-12 (§2 module 18 `plugins`, §3.1 `capabilities.plugins.enabled`):
1036    /// the master gate for out-of-process, manifest-declared plugins (see
1037    /// [`crate::plugins`]'s module doc comment for the ABI). `false` (the
1038    /// default) means `crate::agent`'s tool-registration path never touches
1039    /// [`crate::plugins::discover_and_load`] at all — no directory read, no
1040    /// manifest parse, no subprocess — byte-identical to before this module
1041    /// existed.
1042    pub plugins_enabled: bool,
1043    /// P5-12 (`capabilities.plugins.dirs`): EXTRA directories to scan for
1044    /// `<plugin-name>/plugin.toml` manifests, on top of the always-scanned
1045    /// `$SUPERCODE_HOME/plugins` (see [`crate::plugins::discover_manifests`]).
1046    /// `[capabilities.plugins]` (this field included) is project-forbidden,
1047    /// so this can only ever come from the trusted user/global layer or a
1048    /// preset. Only consulted when [`Self::plugins_enabled`] is `true` AND
1049    /// the workspace is trusted (see [`crate::plugins::is_trusted`]).
1050    pub plugins_dirs: Vec<PathBuf>,
1051}
1052
1053/// P4e (§1.4/§3.1 `core.context_injections`): one named ambient context
1054/// block -- see [`Config::context_injection_blocks`].
1055#[derive(Debug, Clone, PartialEq, Eq)]
1056pub struct ContextInjectionBlock {
1057    /// The block's heading, rendered as `# {name}`.
1058    pub name: String,
1059    /// The block's body text, appended verbatim under the heading.
1060    pub content: String,
1061}
1062
1063impl ContextInjectionBlock {
1064    /// Build a named block.
1065    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
1066        ContextInjectionBlock {
1067            name: name.into(),
1068            content: content.into(),
1069        }
1070    }
1071}
1072
1073/// How queued steering/follow-up messages are drained (S1.7, pi3
1074/// `steeringMode`/`followUpMode`).
1075#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1076pub enum SteeringMode {
1077    /// Deliver every queued message at once.
1078    All,
1079    /// Deliver exactly one queued message per drain point (the default).
1080    #[default]
1081    OneAtATime,
1082}
1083
1084impl SteeringMode {
1085    /// Parse the `"all"` / `"one-at-a-time"` config strings (S3.1).
1086    pub fn parse(s: &str) -> Option<SteeringMode> {
1087        match s {
1088            "all" => Some(SteeringMode::All),
1089            "one-at-a-time" | "one_at_a_time" => Some(SteeringMode::OneAtATime),
1090            _ => None,
1091        }
1092    }
1093}
1094
1095/// A stop-gate hook: receives the would-be-final assistant message; returns
1096/// `Some(reason)` to veto termination and continue the loop (the reason is
1097/// injected as a new user message), or `None` to allow the stop. See
1098/// `Config::stop_gate`.
1099pub type StopGateHook = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
1100
1101impl Default for Config {
1102    fn default() -> Self {
1103        Config {
1104            model: "anthropic/claude-opus-4-8".to_string(),
1105            base_url: OPENROUTER_BASE_URL.to_string(),
1106            api_key: None,
1107            api_key_env: DEFAULT_API_KEY_ENV.to_string(),
1108            api_key_cmd: None,
1109            system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
1110            temperature: None,
1111            max_tokens: None,
1112            max_iterations: 25,
1113            effort: None,
1114            response_format: None,
1115            extra_body: serde_json::Map::new(),
1116            max_total_output_tokens: None,
1117            max_tool_output_bytes: Some(100_000),
1118            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
1119            additional_dirs: Vec::new(),
1120            load_project_context: false,
1121            sandbox: crate::tools::SandboxPolicy::default(),
1122            approval: ApprovalPolicy::default(),
1123            auto_approved_tools: std::collections::HashSet::new(),
1124            tool_deny_patterns: Vec::new(),
1125            tool_allow_patterns: Vec::new(),
1126            approval_handler: None,
1127            pre_tool_hook: None,
1128            post_tool_hook: None,
1129            prompts: default_prompts(),
1130            compact_after_messages: None,
1131            tool_overrides: HashMap::new(),
1132            tool_advertising: ToolAdvertising::default(),
1133            extra_headers: HashMap::new(),
1134            event_sink: None,
1135            cache_plan: CachePlan::default(),
1136            reduction_policy: ReductionPolicySettings::default(),
1137            handoff_enabled: true,
1138            tool_schema_tier: crate::tools::SchemaTier::default(),
1139            cache_warnings: true,
1140            module_registry: false,
1141            module_activation: crate::modules::ModuleActivation::default(),
1142            core_tools_enabled: ["read_file", "bash", "edit_file", "write_file"]
1143                .iter()
1144                .map(|s| s.to_string())
1145                .collect(),
1146            skills_enabled: false,
1147            small_model: None,
1148            model_fallback: Vec::new(),
1149            env_context: false,
1150            project_root_markers: vec![".git".to_string()],
1151            project_doc_max_bytes: None,
1152            instruction_imports: false,
1153            retry_enabled: true,
1154            retry_max_retries: None,
1155            retry_base_delay_ms: None,
1156            compaction_reserve_tokens: None,
1157            compaction_keep_recent_tokens: None,
1158            compaction_focus_instructions: None,
1159            auto_title: false,
1160            steering_mode: SteeringMode::default(),
1161            follow_up_mode: SteeringMode::default(),
1162            stop_gate: None,
1163            read_file_multimodal: false,
1164            edit_file_require_read_before_edit: false,
1165            edit_file_notebook_aware: false,
1166            shell_env_snapshot: false,
1167            doom_loop_threshold: None,
1168            nested_instructions: false,
1169            model_switch_allow_switch: false,
1170            context_injections: false,
1171            context_injection_blocks: Vec::new(),
1172            // P4e: `true` because today's behavior (before this master gate
1173            // existed) is "compaction fires whenever a trigger is
1174            // configured" -- a default of `true` preserves that exactly;
1175            // only an explicit `false` newly suppresses it.
1176            compaction_enabled: true,
1177            parallel_tool_calls: false,
1178            session_git_metadata: false,
1179            session_dir: None,
1180            session_persist: true,
1181            session_name: None,
1182            session_retention_days: None,
1183            session_export_format: crate::human_export::HumanExportFormat::default(),
1184            permissions_enabled: false,
1185            permissions_ask_patterns: Vec::new(),
1186            permissions_protected_paths: Vec::new(),
1187            network_policy: None,
1188            sandbox_os_enabled: None,
1189            sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
1190            sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
1191            subagents_enabled: false,
1192            subagents_max_depth: 2,
1193            subagents_max_concurrent: 4,
1194            subagents_background: false,
1195            subagents_background_prompts: None,
1196            subagents_claude_agent_alias: false,
1197            claude_runtime_tools_enabled: false,
1198            subagents_definitions: HashMap::new(),
1199            subagent_depth: 0,
1200            tui_enabled: false,
1201            tui_theme: "dark".to_string(),
1202            tui_vim_mode: false,
1203            tui_keymap: HashMap::new(),
1204            session_tree_enabled: false,
1205            session_tree_branch_summaries: false,
1206            session_tree_labels: false,
1207            tools_background_enabled: false,
1208            tools_background_max_concurrent: crate::background::DEFAULT_MAX_CONCURRENT,
1209            tools_background_max_output_bytes: crate::background::DEFAULT_MAX_OUTPUT_BYTES,
1210            checkpoint_enabled: false,
1211            checkpoint_retain: crate::checkpoint::DEFAULT_RETAIN,
1212            checkpoint_dir: None,
1213            lsp_enabled: false,
1214            lsp_servers: Vec::new(),
1215            lsp_max_diagnostics: crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS,
1216            lsp_timeout_secs: crate::lsp::DEFAULT_LSP_TIMEOUT_SECS,
1217            formatters_enabled: false,
1218            formatters: Vec::new(),
1219            formatters_diff_back: true,
1220            formatters_timeout_secs: crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS,
1221            trust_enabled: false,
1222            trust_default: crate::plugins::TrustDecision::Ask,
1223            plugins_enabled: false,
1224            plugins_dirs: Vec::new(),
1225        }
1226    }
1227}
1228
1229impl Config {
1230    /// Start building a [`Config`] from defaults.
1231    pub fn builder() -> ConfigBuilder {
1232        ConfigBuilder {
1233            config: Config::default(),
1234        }
1235    }
1236
1237    /// Whether a tool is enabled given the overrides (defaults to enabled).
1238    pub fn tool_enabled(&self, name: &str) -> bool {
1239        self.tool_overrides
1240            .get(name)
1241            .and_then(|o| o.enabled)
1242            .unwrap_or(true)
1243    }
1244
1245    /// Whether a tool call requires approval before it runs, given the
1246    /// policy, the auto-approve allowlist, and (P4) the deny/allow glob
1247    /// PATTERN lists — see [`Self::tool_deny_patterns`]/
1248    /// [`Self::tool_allow_patterns`]'s doc comments for the exact
1249    /// semantics. Both are empty by default, so this is byte-identical to
1250    /// pre-P4 behavior for any `Config` that doesn't set them.
1251    pub fn needs_approval(&self, tool: &str) -> bool {
1252        // Deny wins unconditionally, even under `Never` — a deny pattern is
1253        // a hard floor, not just another allowlist entry.
1254        if self.tool_deny_patterns.iter().any(|p| glob_match(p, tool)) {
1255            return true;
1256        }
1257        match self.approval {
1258            ApprovalPolicy::Never => false,
1259            // P5-1: this coarse, tool-name-only gate has no model-escalation
1260            // signal to consult (that requires the canonicalized-command
1261            // context only `crate::permissions`'s richer gate has), so
1262            // `ModelRequested` is treated the same, conservative way
1263            // `OnRequest` is here — the safe simplification documented on
1264            // `ApprovalPolicy::ModelRequested` itself. The P5-1 engine
1265            // (active when `Self::permissions_enabled` is `true`) is where
1266            // Codex's real "mostly silent, escalation asks" posture is
1267            // approximated instead.
1268            ApprovalPolicy::OnRequest | ApprovalPolicy::ModelRequested => {
1269                !self.auto_approved_tools.contains(tool)
1270                    && !self.tool_allow_patterns.iter().any(|p| glob_match(p, tool))
1271            }
1272            ApprovalPolicy::Untrusted => true,
1273        }
1274    }
1275
1276    /// The effective description for a tool, applying any override.
1277    pub fn tool_description<'a>(&'a self, name: &str, builtin: &'a str) -> &'a str {
1278        self.tool_overrides
1279            .get(name)
1280            .and_then(|o| o.description.as_deref())
1281            .unwrap_or(builtin)
1282    }
1283
1284    /// The effective schema tier for a tool (TR-8/T5): a per-tool override if
1285    /// set, else the global [`Self::tool_schema_tier`].
1286    pub fn schema_tier_for(&self, name: &str) -> crate::tools::SchemaTier {
1287        self.tool_overrides
1288            .get(name)
1289            .and_then(|o| o.schema_tier)
1290            .unwrap_or(self.tool_schema_tier)
1291    }
1292}
1293
1294/// A single tool's file-settable overrides — the `ConfigProfile` mirror of
1295/// [`ToolOverride`] (COMPOSABLE-HARNESS-DESIGN.md §3.1 `[core.tools.<name>]`,
1296/// §3.2 mapping row `core.tools.enabled` + `[core.tools.<n>].*`).
1297#[derive(Debug, Clone, Default, serde::Deserialize)]
1298pub struct ToolOverrideProfile {
1299    /// `Some(false)` hides the tool from the model entirely.
1300    pub enabled: Option<bool>,
1301    /// Replaces the tool's built-in description.
1302    pub description: Option<String>,
1303    /// Per-tool schema tier: `full` | `medium` | `minimal`.
1304    pub schema_tier: Option<String>,
1305    /// P4e (§3.1 `core.tools.bash.timeout_secs`, S14) -- see
1306    /// `ToolOverride::timeout_secs`. Only meaningful on the `bash` entry.
1307    pub timeout_secs: Option<u64>,
1308}
1309
1310/// The serializable subset of a [`Config`] that can live in a config file.
1311/// (Callbacks/handlers are code-only and are not represented here.)
1312///
1313/// Grown from its original 9 fields to the P1 §3.2 surface
1314/// (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P1" migration step): every `[core]`
1315/// scalar/table/array `Config` maps to lives here so it becomes file-settable
1316/// for the first time, per the design's stated framing gap (§3.0).
1317#[derive(Debug, Clone, Default, serde::Deserialize)]
1318pub struct ConfigProfile {
1319    /// Model id.
1320    pub model: Option<String>,
1321    /// Endpoint base URL.
1322    pub base_url: Option<String>,
1323    /// Environment variable to read the API key from (§3.1 `core.api_key_env`;
1324    /// §3.2: "today absent from BOTH files").
1325    pub api_key_env: Option<String>,
1326    /// Credential-helper command (§3.1 `core.api_key_cmd`, D6 row) — see
1327    /// [`Config::api_key_cmd`].
1328    pub api_key_cmd: Option<String>,
1329    /// System prompt.
1330    pub system_prompt: Option<String>,
1331    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): an additive suffix
1332    /// composed onto whatever [`Self::system_prompt`] resolves to (the
1333    /// profile's own value if set, else whatever the builder already had —
1334    /// see [`ConfigBuilder::apply_profile`]'s composition order), distinct
1335    /// from REPLACING it. `[project-forbidden]`, same trust boundary as
1336    /// `system_prompt` (§3.3: prompt injection).
1337    pub append_system_prompt: Option<String>,
1338    /// Sampling temperature.
1339    pub temperature: Option<f32>,
1340    /// Output token cap per request.
1341    pub max_tokens: Option<u32>,
1342    /// Reasoning/effort level.
1343    pub effort: Option<String>,
1344    /// Sandbox policy: `read_only` | `workspace_write` | `danger_full_access`.
1345    pub sandbox: Option<String>,
1346    /// Approval policy: `never` | `on_request` | `untrusted`.
1347    pub approval: Option<String>,
1348    /// Auto-load CLAUDE.md / AGENTS.md.
1349    pub project_context: Option<bool>,
1350    /// Per-`send` iteration budget (§3.1 `core.max_iterations`).
1351    pub max_iterations: Option<usize>,
1352    /// Extra roots beyond `cwd` (§3.1 `core.additional_dirs`); arrays
1353    /// replace wholesale on overlay (§3.3).
1354    pub additional_dirs: Option<Vec<String>>,
1355    /// Compact once the conversation exceeds this many messages (§3.1
1356    /// `core.compaction.after_messages`; `0` = trigger off).
1357    pub compact_after_messages: Option<usize>,
1358    /// Prompt-caching plan: `off` | `imported_prefix` (§3.1
1359    /// `capabilities.cache.plan`).
1360    pub cache_plan: Option<String>,
1361    /// How tools are advertised: `full` | `deferred` (§3.1
1362    /// `capabilities.deferred_tools`).
1363    pub tool_advertising: Option<String>,
1364    /// The eagerly-advertised core allowlist when `tool_advertising =
1365    /// "deferred"` (§3.1 `capabilities.deferred_tools.core`); arrays replace.
1366    pub tool_advertising_core: Option<Vec<String>>,
1367    /// Global tool-schema tier: `full` | `medium` | `minimal` (§3.1
1368    /// `core.tools.schema_tier`).
1369    pub schema_tier: Option<String>,
1370    /// Tools that never require approval under `ApprovalPolicy::OnRequest`
1371    /// (§3.1 `capabilities.permissions.auto_approved_tools`); arrays replace.
1372    pub auto_approved_tools: Option<Vec<String>>,
1373    /// P4: deny-pattern generalization of `auto_approved_tools` (§3.1
1374    /// `capabilities.permissions.rules.deny`) — see
1375    /// [`Config::tool_deny_patterns`]. Arrays replace.
1376    pub tool_deny_patterns: Option<Vec<String>>,
1377    /// P4: allow-pattern generalization of `auto_approved_tools` (§3.1
1378    /// `capabilities.permissions.rules.allow`) — see
1379    /// [`Config::tool_allow_patterns`]. Arrays replace.
1380    pub tool_allow_patterns: Option<Vec<String>>,
1381    /// Extra HTTP headers merged in (§3.1 `core.extra_headers`); a table,
1382    /// merged key-wise on overlay (§3.3).
1383    pub extra_headers: Option<HashMap<String, String>>,
1384    /// Extra request-body fields merged in (§3.1 `core.extra_body`); a
1385    /// table, merged key-wise on overlay (§3.3).
1386    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
1387    /// Max bytes of a single tool result (§3.1 `core.max_tool_output_bytes`).
1388    pub max_tool_output_bytes: Option<usize>,
1389    /// Cap on cumulative output tokens per `send` loop (§3.1
1390    /// `core.max_total_output_tokens`).
1391    pub max_total_output_tokens: Option<u64>,
1392    /// Named prompt templates (§3.1 `[core.prompts]`); a table, merged
1393    /// key-wise (new/overridden names layer onto the built-ins, they don't
1394    /// wholesale-replace them).
1395    pub prompts: Option<HashMap<String, String>>,
1396    /// Per-tool enable/disable + description/schema-tier overrides, keyed
1397    /// by tool name (§3.1 `[core.tools.<name>]`, §3.2 "today in no file");
1398    /// a table, merged key-wise per tool.
1399    pub tool_overrides: Option<HashMap<String, ToolOverrideProfile>>,
1400
1401    /// P4b (S3.1 `core.env_context`) -- see `Config::env_context`.
1402    pub env_context: Option<bool>,
1403    /// P4b (S3.1 `core.project_root_markers`) -- see
1404    /// `Config::project_root_markers`; arrays replace.
1405    pub project_root_markers: Option<Vec<String>>,
1406    /// P4b (S3.1 `core.project_doc_max_bytes`) -- see
1407    /// `Config::project_doc_max_bytes`.
1408    pub project_doc_max_bytes: Option<usize>,
1409    /// P4b (S3.1 `core.instruction_imports`) -- see
1410    /// `Config::instruction_imports`.
1411    pub instruction_imports: Option<bool>,
1412    /// P4b (S3.1 `core.retry.enabled`) -- see `Config::retry_enabled`.
1413    pub retry_enabled: Option<bool>,
1414    /// P4b (S3.1 `core.retry.max_retries`) -- see `Config::retry_max_retries`.
1415    pub retry_max_retries: Option<u32>,
1416    /// P4b (S3.1 `core.retry.base_delay_ms`) -- see
1417    /// `Config::retry_base_delay_ms`.
1418    pub retry_base_delay_ms: Option<u64>,
1419    /// P4b (S3.1 `core.compaction.reserve_tokens`) -- see
1420    /// `Config::compaction_reserve_tokens`.
1421    pub compaction_reserve_tokens: Option<u64>,
1422    /// P4b (S3.1 `core.compaction.keep_recent_tokens`) -- see
1423    /// `Config::compaction_keep_recent_tokens`.
1424    pub compaction_keep_recent_tokens: Option<u64>,
1425    /// P4b (S3.1 `core.compaction.focus_instructions`) -- see
1426    /// `Config::compaction_focus_instructions`.
1427    pub compaction_focus_instructions: Option<String>,
1428    /// P4b (S3.1 `core.session.auto_title`) -- see `Config::auto_title`.
1429    pub auto_title: Option<bool>,
1430    /// P4b (S3.1 `core.steering.steering_mode`) -- see
1431    /// `Config::steering_mode`.
1432    pub steering_mode: Option<String>,
1433    /// P4b (S3.1 `core.steering.follow_up_mode`) -- see
1434    /// `Config::follow_up_mode`.
1435    pub follow_up_mode: Option<String>,
1436
1437    /// P4c (S3.1 `core.tools.read_file.multimodal`) -- see
1438    /// `Config::read_file_multimodal`.
1439    pub read_file_multimodal: Option<bool>,
1440    /// P4c (S3.1 `core.tools.edit_file.require_read_before_edit`) -- see
1441    /// `Config::edit_file_require_read_before_edit`.
1442    pub edit_file_require_read_before_edit: Option<bool>,
1443    /// P4c (S3.1 `core.tools.edit_file.notebook_aware`) -- see
1444    /// `Config::edit_file_notebook_aware`.
1445    pub edit_file_notebook_aware: Option<bool>,
1446    /// P4c (S3.1 `core.shell_env_snapshot`) -- see
1447    /// `Config::shell_env_snapshot`.
1448    pub shell_env_snapshot: Option<bool>,
1449    /// P4c (S3.1 `core.doom_loop_threshold`) -- see
1450    /// `Config::doom_loop_threshold`.
1451    pub doom_loop_threshold: Option<u32>,
1452    /// P4c (S3.1 `core.nested_instructions`) -- see
1453    /// `Config::nested_instructions`.
1454    pub nested_instructions: Option<bool>,
1455    /// P4c (S3.1 `core.model_switch.allow_switch`) -- see
1456    /// `Config::model_switch_allow_switch`.
1457    pub model_switch_allow_switch: Option<bool>,
1458
1459    /// P4e (S3.1 `core.context_injections`) -- see
1460    /// `Config::context_injections`. Only the boolean gate is
1461    /// file/profile-settable; `Config::context_injection_blocks`' actual
1462    /// content is code-only (see its doc comment).
1463    pub context_injections: Option<bool>,
1464    /// P4e (S3.1 `core.compaction.enabled`) -- see
1465    /// `Config::compaction_enabled`.
1466    pub compaction_enabled: Option<bool>,
1467    /// P4e (S3.1 `core.parallel_tool_calls`) -- see
1468    /// `Config::parallel_tool_calls`.
1469    pub parallel_tool_calls: Option<bool>,
1470    /// P4e (S3.1 `core.session.git_metadata`) -- see
1471    /// `Config::session_git_metadata`.
1472    pub session_git_metadata: Option<bool>,
1473    /// P4e (S3.1 `core.session.dir`) -- see `Config::session_dir`.
1474    pub session_dir: Option<String>,
1475    /// P4e (S3.1 `core.session.persist`) -- see `Config::session_persist`.
1476    pub session_persist: Option<bool>,
1477    /// P4e (S3.1 `core.session.name`) -- see `Config::session_name`.
1478    pub session_name: Option<String>,
1479    /// P4e (S3.1 `core.session.retention_days`) -- see
1480    /// `Config::session_retention_days`.
1481    pub session_retention_days: Option<u32>,
1482    /// P4e (S3.1 `core.session.export_format`) -- see
1483    /// `Config::session_export_format`. `"text"` | `"html"`; an
1484    /// unrecognized string is a no-op warning, like `steering_mode`.
1485    pub session_export_format: Option<String>,
1486}
1487
1488/// A config file: a set of named profiles (the analog of Codex `-p/--profile`).
1489/// This is the **SDK/embedder** config surface; the supercode CLI uses a
1490/// separate TOML config (`userconfig::FileConfig` in the `cli` crate) and does
1491/// not expose this file or a `--profile` flag.
1492#[derive(Debug, Clone, Default, serde::Deserialize)]
1493pub struct ConfigFile {
1494    /// Profiles keyed by name.
1495    #[serde(default)]
1496    pub profiles: HashMap<String, ConfigProfile>,
1497}
1498
1499impl Config {
1500    /// Load a named profile from a JSON config file into a builder. Layered:
1501    /// start from defaults, then apply the named profile's set fields.
1502    pub fn from_profile_file(
1503        path: impl AsRef<std::path::Path>,
1504        profile: &str,
1505    ) -> crate::Result<ConfigBuilder> {
1506        let text = std::fs::read_to_string(path.as_ref())?;
1507        let file: ConfigFile = serde_json::from_str(&text).map_err(crate::Error::Decode)?;
1508        let p = file
1509            .profiles
1510            .get(profile)
1511            .ok_or_else(|| crate::Error::Other(format!("no profile `{profile}` in config file")))?;
1512        Ok(ConfigBuilder::default().apply_profile(p))
1513    }
1514}
1515
1516impl ConfigBuilder {
1517    /// Apply the set fields of a [`ConfigProfile`] over the current builder.
1518    pub fn apply_profile(mut self, p: &ConfigProfile) -> Self {
1519        if let Some(m) = &p.model {
1520            self.config.model = m.clone();
1521        }
1522        if let Some(u) = &p.base_url {
1523            self.config.base_url = u.clone();
1524        }
1525        if let Some(s) = &p.system_prompt {
1526            self.config.system_prompt = s.clone();
1527        }
1528        if let Some(extra) = &p.append_system_prompt {
1529            // P4 (§3.1 `core.append_system_prompt`): additive, composed onto
1530            // whatever `system_prompt` is on the builder AT THIS POINT —
1531            // either the value just applied above, or whatever the caller
1532            // already set/left at its `Config::default()` — never a
1533            // replacement. This intentionally runs regardless of whether
1534            // `p.system_prompt` was set, so an append-only profile still
1535            // composes onto the existing base.
1536            self.config.system_prompt = format!("{}\n\n{extra}", self.config.system_prompt);
1537        }
1538        self.config.temperature = p.temperature.or(self.config.temperature);
1539        self.config.max_tokens = p.max_tokens.or(self.config.max_tokens);
1540        if p.effort.is_some() {
1541            self.config.effort = p.effort.clone();
1542        }
1543        if let Some(sb) = &p.sandbox {
1544            // Fail *safe*: an unrecognized value (typo, future variant) must not
1545            // silently grant full filesystem access. Only the explicit
1546            // danger string opts out of confinement.
1547            self.config.sandbox = match sb.as_str() {
1548                "read_only" | "read-only" | "readonly" => crate::tools::SandboxPolicy::ReadOnly,
1549                "workspace_write" | "workspace-write" => {
1550                    crate::tools::SandboxPolicy::WorkspaceWrite
1551                }
1552                "danger_full_access" | "danger-full-access" => {
1553                    crate::tools::SandboxPolicy::DangerFullAccess
1554                }
1555                other => {
1556                    tracing::warn!(
1557                        "unknown sandbox policy `{other}` in profile; defaulting to read_only"
1558                    );
1559                    crate::tools::SandboxPolicy::ReadOnly
1560                }
1561            };
1562        }
1563        if let Some(ap) = &p.approval {
1564            // Fail safe: an unrecognized value defaults to the most-prompting
1565            // policy, never to `never`.
1566            self.config.approval = match ap.as_str() {
1567                "on_request" | "on-request" => ApprovalPolicy::OnRequest,
1568                "untrusted" => ApprovalPolicy::Untrusted,
1569                "never" => ApprovalPolicy::Never,
1570                // P5-1 (§3.2 S8): cx-parity's real intended posture — see
1571                // `ApprovalPolicy::ModelRequested`'s doc comment.
1572                "model_requested" | "model-requested" => ApprovalPolicy::ModelRequested,
1573                other => {
1574                    tracing::warn!(
1575                        "unknown approval policy `{other}` in profile; defaulting to untrusted"
1576                    );
1577                    ApprovalPolicy::Untrusted
1578                }
1579            };
1580        }
1581        if let Some(pc) = p.project_context {
1582            self.config.load_project_context = pc;
1583        }
1584        if let Some(env) = &p.api_key_env {
1585            self.config.api_key_env = env.clone();
1586        }
1587        if let Some(cmd) = &p.api_key_cmd {
1588            self.config.api_key_cmd = Some(cmd.clone());
1589        }
1590        // Scalars replace (§3.3).
1591        if let Some(n) = p.max_iterations {
1592            self.config.max_iterations = n;
1593        }
1594        // Arrays replace wholesale (§3.3), not append — predictable overlay.
1595        if let Some(dirs) = &p.additional_dirs {
1596            self.config.additional_dirs = dirs.iter().map(std::path::PathBuf::from).collect();
1597        }
1598        if let Some(n) = p.compact_after_messages {
1599            self.config.compact_after_messages = Some(n);
1600        }
1601        if let Some(plan) = &p.cache_plan {
1602            // Fail safe: an unrecognized value never silently opts into
1603            // caching behavior the operator didn't ask for.
1604            self.config.cache_plan = match plan.as_str() {
1605                "off" => CachePlan::Off,
1606                "imported_prefix" | "imported-prefix" => CachePlan::ImportedPrefix,
1607                other => {
1608                    tracing::warn!("unknown cache plan `{other}` in profile; defaulting to off");
1609                    CachePlan::Off
1610                }
1611            };
1612        }
1613        if p.tool_advertising.is_some() || p.tool_advertising_core.is_some() {
1614            // F6 fix: per-key replace (§3.3) — neither key alone may clobber
1615            // the other's current value. Compute the effective core list
1616            // FIRST (the profile's new value if given, else whatever's
1617            // already active) so an explicit `"deferred"` mode with no
1618            // `_core` doesn't wipe an existing list, then only change the
1619            // MODE if the profile actually set one — setting `_core` alone
1620            // must not silently reset the mode to `Full` (which previously
1621            // discarded the array outright, since `Full` ignores it).
1622            let existing_core = match &self.config.tool_advertising {
1623                ToolAdvertising::Deferred { core } => core.clone(),
1624                ToolAdvertising::Full => Vec::new(),
1625            };
1626            let core = p.tool_advertising_core.clone().unwrap_or(existing_core);
1627            self.config.tool_advertising = match p.tool_advertising.as_deref() {
1628                Some("deferred") => ToolAdvertising::Deferred { core },
1629                Some("full") => ToolAdvertising::Full,
1630                Some(other) => {
1631                    tracing::warn!(
1632                        "unknown tool_advertising mode `{other}` in profile; defaulting to full"
1633                    );
1634                    ToolAdvertising::Full
1635                }
1636                None => match &self.config.tool_advertising {
1637                    // Mode untouched — only refresh the core list if
1638                    // already `Deferred` (`Full` has nowhere to put one).
1639                    ToolAdvertising::Deferred { .. } => ToolAdvertising::Deferred { core },
1640                    ToolAdvertising::Full => ToolAdvertising::Full,
1641                },
1642            };
1643        }
1644        if let Some(tier) = &p.schema_tier {
1645            // Fail safe: unrecognized value keeps the verbose (never
1646            // under-informative) default rather than guessing a shrink tier.
1647            self.config.tool_schema_tier =
1648                crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
1649                    tracing::warn!("unknown schema tier `{tier}` in profile; defaulting to full");
1650                    crate::tools::SchemaTier::Full
1651                });
1652        }
1653        // Arrays replace wholesale (§3.3).
1654        if let Some(tools) = &p.auto_approved_tools {
1655            self.config.auto_approved_tools = tools.iter().cloned().collect();
1656        }
1657        if let Some(patterns) = &p.tool_deny_patterns {
1658            self.config.tool_deny_patterns = patterns.clone();
1659        }
1660        if let Some(patterns) = &p.tool_allow_patterns {
1661            self.config.tool_allow_patterns = patterns.clone();
1662        }
1663        // Tables merge key-wise (§3.3), not wholesale replace.
1664        if let Some(headers) = &p.extra_headers {
1665            for (k, v) in headers {
1666                self.config.extra_headers.insert(k.clone(), v.clone());
1667            }
1668        }
1669        if let Some(body) = &p.extra_body {
1670            for (k, v) in body {
1671                self.config.extra_body.insert(k.clone(), v.clone());
1672            }
1673        }
1674        if let Some(n) = p.max_tool_output_bytes {
1675            self.config.max_tool_output_bytes = Some(n);
1676        }
1677        if let Some(n) = p.max_total_output_tokens {
1678            self.config.max_total_output_tokens = Some(n);
1679        }
1680        if let Some(prompts) = &p.prompts {
1681            for (k, v) in prompts {
1682                self.config.prompts.insert(k.clone(), v.clone());
1683            }
1684        }
1685        if let Some(overrides) = &p.tool_overrides {
1686            for (name, o) in overrides {
1687                let entry = self.config.tool_overrides.entry(name.clone()).or_default();
1688                if let Some(en) = o.enabled {
1689                    entry.enabled = Some(en);
1690                }
1691                if let Some(desc) = &o.description {
1692                    entry.description = Some(desc.clone());
1693                }
1694                if let Some(tier) = &o.schema_tier {
1695                    entry.schema_tier = Some(crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
1696                        tracing::warn!(
1697                            "unknown schema tier `{tier}` in tool override `{name}`; defaulting to full"
1698                        );
1699                        crate::tools::SchemaTier::Full
1700                    }));
1701                }
1702                if let Some(t) = o.timeout_secs {
1703                    entry.timeout_secs = Some(t);
1704                }
1705            }
1706        }
1707        // P4b: scalars replace (S3.3).
1708        if let Some(v) = p.env_context {
1709            self.config.env_context = v;
1710        }
1711        if let Some(v) = &p.project_root_markers {
1712            self.config.project_root_markers = v.clone();
1713        }
1714        if let Some(v) = p.project_doc_max_bytes {
1715            self.config.project_doc_max_bytes = Some(v);
1716        }
1717        if let Some(v) = p.instruction_imports {
1718            self.config.instruction_imports = v;
1719        }
1720        if let Some(v) = p.retry_enabled {
1721            self.config.retry_enabled = v;
1722        }
1723        if let Some(v) = p.retry_max_retries {
1724            self.config.retry_max_retries = Some(v);
1725        }
1726        if let Some(v) = p.retry_base_delay_ms {
1727            self.config.retry_base_delay_ms = Some(v);
1728        }
1729        if let Some(v) = p.compaction_reserve_tokens {
1730            self.config.compaction_reserve_tokens = Some(v);
1731        }
1732        if let Some(v) = p.compaction_keep_recent_tokens {
1733            self.config.compaction_keep_recent_tokens = Some(v);
1734        }
1735        if let Some(v) = &p.compaction_focus_instructions {
1736            self.config.compaction_focus_instructions = Some(v.clone());
1737        }
1738        if let Some(v) = p.auto_title {
1739            self.config.auto_title = v;
1740        }
1741        if let Some(mode) = &p.steering_mode {
1742            // Fail safe: an unrecognized value keeps the current setting
1743            // rather than guessing.
1744            match SteeringMode::parse(mode) {
1745                Some(m) => self.config.steering_mode = m,
1746                None => tracing::warn!("unknown steering_mode `{mode}` in profile; ignoring"),
1747            }
1748        }
1749        if let Some(mode) = &p.follow_up_mode {
1750            match SteeringMode::parse(mode) {
1751                Some(m) => self.config.follow_up_mode = m,
1752                None => tracing::warn!("unknown follow_up_mode `{mode}` in profile; ignoring"),
1753            }
1754        }
1755        // P4c: scalars replace (S3.3).
1756        if let Some(v) = p.read_file_multimodal {
1757            self.config.read_file_multimodal = v;
1758        }
1759        if let Some(v) = p.edit_file_require_read_before_edit {
1760            self.config.edit_file_require_read_before_edit = v;
1761        }
1762        if let Some(v) = p.edit_file_notebook_aware {
1763            self.config.edit_file_notebook_aware = v;
1764        }
1765        if let Some(v) = p.shell_env_snapshot {
1766            self.config.shell_env_snapshot = v;
1767        }
1768        if let Some(v) = p.doom_loop_threshold {
1769            self.config.doom_loop_threshold = Some(v);
1770        }
1771        if let Some(v) = p.nested_instructions {
1772            self.config.nested_instructions = v;
1773        }
1774        if let Some(v) = p.model_switch_allow_switch {
1775            self.config.model_switch_allow_switch = v;
1776        }
1777        // P4e: scalars replace (S3.3).
1778        if let Some(v) = p.context_injections {
1779            self.config.context_injections = v;
1780        }
1781        if let Some(v) = p.compaction_enabled {
1782            self.config.compaction_enabled = v;
1783        }
1784        if let Some(v) = p.parallel_tool_calls {
1785            self.config.parallel_tool_calls = v;
1786        }
1787        if let Some(v) = p.session_git_metadata {
1788            self.config.session_git_metadata = v;
1789        }
1790        if let Some(v) = &p.session_dir {
1791            self.config.session_dir = Some(v.clone());
1792        }
1793        if let Some(v) = p.session_persist {
1794            self.config.session_persist = v;
1795        }
1796        if let Some(v) = &p.session_name {
1797            self.config.session_name = Some(v.clone());
1798        }
1799        if let Some(v) = p.session_retention_days {
1800            self.config.session_retention_days = Some(v);
1801        }
1802        if let Some(fmt) = &p.session_export_format {
1803            match crate::human_export::HumanExportFormat::parse(fmt) {
1804                Some(f) => self.config.session_export_format = f,
1805                None => {
1806                    tracing::warn!("unknown session export_format `{fmt}` in profile; ignoring")
1807                }
1808            }
1809        }
1810        self
1811    }
1812}
1813
1814/// Fluent builder for [`Config`].
1815#[derive(Default)]
1816pub struct ConfigBuilder {
1817    config: Config,
1818}
1819
1820impl ConfigBuilder {
1821    /// P4d (design §5.2 P1 CLI-adapter): resume building from an
1822    /// ALREADY-constructed [`Config`] rather than [`Config::default`] — lets
1823    /// a caller that assembled a `Config` with its own precedence logic
1824    /// (e.g. the CLI's `build_config`: flag > env > project > user >
1825    /// interactive-default) layer a narrowly-scoped [`ConfigProfile`] on top
1826    /// via [`Self::apply_profile`] afterward, reusing that method's correct
1827    /// per-key merge semantics (tables merge key-wise, e.g.
1828    /// `tool_overrides`/`prompts`/`extra_headers`/`extra_body`) instead of a
1829    /// second hand-rolled copy of the same merge logic at the call site.
1830    pub fn from_config(config: Config) -> Self {
1831        ConfigBuilder { config }
1832    }
1833
1834    /// Set the model identifier.
1835    pub fn model(mut self, model: impl Into<String>) -> Self {
1836        self.config.model = model.into();
1837        self
1838    }
1839
1840    /// Set the OpenAI-compatible base URL (defaults to OpenRouter).
1841    pub fn base_url(mut self, url: impl Into<String>) -> Self {
1842        self.config.base_url = url.into();
1843        self
1844    }
1845
1846    /// Provide the API key explicitly.
1847    pub fn api_key(mut self, key: impl Into<String>) -> Self {
1848        self.config.api_key = Some(key.into());
1849        self
1850    }
1851
1852    /// Change which environment variable the key is read from.
1853    pub fn api_key_env(mut self, var: impl Into<String>) -> Self {
1854        self.config.api_key_env = var.into();
1855        self
1856    }
1857
1858    /// Set the credential-helper command — see [`Config::api_key_cmd`].
1859    pub fn api_key_cmd(mut self, cmd: impl Into<String>) -> Self {
1860        self.config.api_key_cmd = Some(cmd.into());
1861        self
1862    }
1863
1864    /// Replace the system prompt.
1865    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
1866        self.config.system_prompt = prompt.into();
1867        self
1868    }
1869
1870    /// Set the sampling temperature.
1871    pub fn temperature(mut self, t: f32) -> Self {
1872        self.config.temperature = Some(t);
1873        self
1874    }
1875
1876    /// Set the max output tokens.
1877    pub fn max_tokens(mut self, n: u32) -> Self {
1878        self.config.max_tokens = Some(n);
1879        self
1880    }
1881
1882    /// Set the per-`send` iteration budget.
1883    pub fn max_iterations(mut self, n: usize) -> Self {
1884        self.config.max_iterations = n;
1885        self
1886    }
1887
1888    /// Set the reasoning/effort level (`reasoning_effort`).
1889    pub fn effort(mut self, level: impl Into<String>) -> Self {
1890        self.config.effort = Some(level.into());
1891        self
1892    }
1893
1894    /// Constrain output to a JSON schema (`response_format`).
1895    pub fn response_format(mut self, format: serde_json::Value) -> Self {
1896        self.config.response_format = Some(format);
1897        self
1898    }
1899
1900    /// Merge an extra request-body field (provider-native passthrough).
1901    pub fn extra_body_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
1902        self.config.extra_body.insert(key.into(), value);
1903        self
1904    }
1905
1906    /// Cap cumulative output tokens across one `send` loop.
1907    /// Cap a single tool result at `n` bytes (see
1908    /// [`Config::max_tool_output_bytes`]). Pass `0` only via the field to disable.
1909    pub fn max_tool_output_bytes(mut self, n: usize) -> Self {
1910        self.config.max_tool_output_bytes = Some(n);
1911        self
1912    }
1913
1914    /// Cap on cumulative output tokens across one send loop. Output tokens
1915    /// only; input/prompt tokens are not counted, so this is not a cost cap.
1916    pub fn max_total_output_tokens(mut self, n: u64) -> Self {
1917        self.config.max_total_output_tokens = Some(n);
1918        self
1919    }
1920
1921    /// Set the working directory tools operate in.
1922    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
1923        self.config.cwd = dir.into();
1924        self
1925    }
1926
1927    /// Add an extra root directory (`--add-dir` / multi-root / worktree).
1928    pub fn add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1929        self.config.additional_dirs.push(dir.into());
1930        self
1931    }
1932
1933    /// Enable auto-loading of `CLAUDE.md` / `AGENTS.md` into the system prompt.
1934    pub fn project_context(mut self, enabled: bool) -> Self {
1935        self.config.load_project_context = enabled;
1936        self
1937    }
1938
1939    /// Register a named prompt template (skill / slash command).
1940    pub fn prompt(mut self, name: impl Into<String>, template: impl Into<String>) -> Self {
1941        self.config.prompts.insert(name.into(), template.into());
1942        self
1943    }
1944
1945    /// Compact the conversation once it exceeds `n` messages.
1946    pub fn compact_after_messages(mut self, n: usize) -> Self {
1947        self.config.compact_after_messages = Some(n);
1948        self
1949    }
1950
1951    /// Set the filesystem sandbox policy for write-capable tools.
1952    pub fn sandbox(mut self, policy: crate::tools::SandboxPolicy) -> Self {
1953        self.config.sandbox = policy;
1954        self
1955    }
1956
1957    /// Set the tool-approval policy.
1958    pub fn approval(mut self, policy: ApprovalPolicy) -> Self {
1959        self.config.approval = policy;
1960        self
1961    }
1962
1963    /// Add a tool to the auto-approve allowlist (no approval under `OnRequest`).
1964    pub fn auto_approve_tool(mut self, name: impl Into<String>) -> Self {
1965        self.config.auto_approved_tools.insert(name.into());
1966        self
1967    }
1968
1969    /// P4: add a glob pattern to [`Config::tool_deny_patterns`] — a match
1970    /// forces approval unconditionally, even under `ApprovalPolicy::Never`.
1971    pub fn deny_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
1972        self.config.tool_deny_patterns.push(pattern.into());
1973        self
1974    }
1975
1976    /// P4: add a glob pattern to [`Config::tool_allow_patterns`] — the
1977    /// pattern generalization of [`Self::auto_approve_tool`].
1978    pub fn allow_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
1979        self.config.tool_allow_patterns.push(pattern.into());
1980        self
1981    }
1982
1983    /// Set the handler consulted when a tool call needs approval.
1984    pub fn approval_handler(mut self, handler: ApprovalHandler) -> Self {
1985        self.config.approval_handler = Some(handler);
1986        self
1987    }
1988
1989    /// Set the pre-tool hook (may block a call by returning `Some(reason)`).
1990    pub fn pre_tool_hook(mut self, hook: PreToolHook) -> Self {
1991        self.config.pre_tool_hook = Some(hook);
1992        self
1993    }
1994
1995    /// Set the post-tool hook (observational).
1996    pub fn post_tool_hook(mut self, hook: PostToolHook) -> Self {
1997        self.config.post_tool_hook = Some(hook);
1998        self
1999    }
2000
2001    /// Disable a tool by name.
2002    pub fn disable_tool(mut self, name: impl Into<String>) -> Self {
2003        self.config
2004            .tool_overrides
2005            .entry(name.into())
2006            .or_default()
2007            .enabled = Some(false);
2008        self
2009    }
2010
2011    /// Enable a tool by name (overriding a prior disable).
2012    pub fn enable_tool(mut self, name: impl Into<String>) -> Self {
2013        self.config
2014            .tool_overrides
2015            .entry(name.into())
2016            .or_default()
2017            .enabled = Some(true);
2018        self
2019    }
2020
2021    /// Override the description the model sees for a tool.
2022    pub fn tool_description(
2023        mut self,
2024        name: impl Into<String>,
2025        description: impl Into<String>,
2026    ) -> Self {
2027        self.config
2028            .tool_overrides
2029            .entry(name.into())
2030            .or_default()
2031            .description = Some(description.into());
2032        self
2033    }
2034
2035    /// Set how tools are advertised to the model (B6).
2036    pub fn tool_advertising(mut self, advertising: ToolAdvertising) -> Self {
2037        self.config.tool_advertising = advertising;
2038        self
2039    }
2040
2041    /// Set the global tool-schema tier (TR-8/T5): how verbose ADVERTISED
2042    /// tool schemas are. Per-tool overrides ([`Self::tool_schema_tier`])
2043    /// still win for the specific tools they name.
2044    pub fn schema_tier(mut self, tier: crate::tools::SchemaTier) -> Self {
2045        self.config.tool_schema_tier = tier;
2046        self
2047    }
2048
2049    /// Override the schema tier for a single tool (TR-8/T5), regardless of
2050    /// the global knob — e.g. keep one load-bearing tool at `Full` while
2051    /// everything else shrinks to `Minimal`.
2052    pub fn tool_schema_tier(
2053        mut self,
2054        name: impl Into<String>,
2055        tier: crate::tools::SchemaTier,
2056    ) -> Self {
2057        self.config
2058            .tool_overrides
2059            .entry(name.into())
2060            .or_default()
2061            .schema_tier = Some(tier);
2062        self
2063    }
2064
2065    /// Add an extra HTTP header sent on every request.
2066    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2067        self.config.extra_headers.insert(key.into(), value.into());
2068        self
2069    }
2070
2071    /// Attach a streaming event sink.
2072    pub fn event_sink(mut self, sink: EventSink) -> Self {
2073        self.config.event_sink = Some(sink);
2074        self
2075    }
2076
2077    /// Set the prompt-caching plan (B7).
2078    pub fn cache_plan(mut self, plan: CachePlan) -> Self {
2079        self.config.cache_plan = plan;
2080        self
2081    }
2082
2083    /// UX-26 (B7-warn): enable/disable the cache-cold warning (default on).
2084    pub fn cache_warnings(mut self, enabled: bool) -> Self {
2085        self.config.cache_warnings = enabled;
2086        self
2087    }
2088
2089    /// P3: turn on the `[experimental] module_registry` gate — see
2090    /// [`Config::module_registry`].
2091    pub fn module_registry(mut self, enabled: bool) -> Self {
2092        self.config.module_registry = enabled;
2093        self
2094    }
2095
2096    /// P3: set the resolved module-activation set — see
2097    /// [`Config::module_activation`].
2098    pub fn module_activation(mut self, activation: crate::modules::ModuleActivation) -> Self {
2099        self.config.module_activation = activation;
2100        self
2101    }
2102
2103    /// P3: set the effective `[core.tools] enabled` list — see
2104    /// [`Config::core_tools_enabled`].
2105    pub fn core_tools_enabled(mut self, tools: Vec<String>) -> Self {
2106        self.config.core_tools_enabled = tools;
2107        self
2108    }
2109
2110    /// P3: set `[core.skills].enabled` — see [`Config::skills_enabled`].
2111    pub fn skills_enabled(mut self, enabled: bool) -> Self {
2112        self.config.skills_enabled = enabled;
2113        self
2114    }
2115
2116    /// P4: set the small/utility model id — see [`Config::small_model`].
2117    pub fn small_model(mut self, model: impl Into<String>) -> Self {
2118        self.config.small_model = Some(model.into());
2119        self
2120    }
2121
2122    /// P4: set the model failure-fallback chain — see [`Config::model_fallback`].
2123    pub fn model_fallback(mut self, chain: Vec<String>) -> Self {
2124        self.config.model_fallback = chain;
2125        self
2126    }
2127
2128    /// P4b: turn on the environment-context block — see [`Config::env_context`].
2129    pub fn env_context(mut self, enabled: bool) -> Self {
2130        self.config.env_context = enabled;
2131        self
2132    }
2133
2134    /// P4b: set the project-root marker filenames — see
2135    /// [`Config::project_root_markers`].
2136    pub fn project_root_markers(mut self, markers: Vec<String>) -> Self {
2137        self.config.project_root_markers = markers;
2138        self
2139    }
2140
2141    /// P4b: cap the total bytes of assembled instruction-file content — see
2142    /// [`Config::project_doc_max_bytes`].
2143    pub fn project_doc_max_bytes(mut self, n: usize) -> Self {
2144        self.config.project_doc_max_bytes = Some(n);
2145        self
2146    }
2147
2148    /// P4b: turn on `@path` instruction imports — see
2149    /// [`Config::instruction_imports`].
2150    pub fn instruction_imports(mut self, enabled: bool) -> Self {
2151        self.config.instruction_imports = enabled;
2152        self
2153    }
2154
2155    /// P4b: configure request retry with backoff — see
2156    /// [`Config::retry_enabled`]. `max_retries`/`base_delay_ms` override the
2157    /// transport's built-in defaults when `Some`.
2158    pub fn retry(
2159        mut self,
2160        enabled: bool,
2161        max_retries: Option<u32>,
2162        base_delay_ms: Option<u64>,
2163    ) -> Self {
2164        self.config.retry_enabled = enabled;
2165        self.config.retry_max_retries = max_retries;
2166        self.config.retry_base_delay_ms = base_delay_ms;
2167        self
2168    }
2169
2170    /// P4b: turn on the compaction token-pressure trigger — see
2171    /// [`Config::compaction_reserve_tokens`].
2172    pub fn compaction_pressure(mut self, reserve_tokens: u64, keep_recent_tokens: u64) -> Self {
2173        self.config.compaction_reserve_tokens = Some(reserve_tokens);
2174        self.config.compaction_keep_recent_tokens = Some(keep_recent_tokens);
2175        self
2176    }
2177
2178    /// P4b: set the compaction focus instructions — see
2179    /// [`Config::compaction_focus_instructions`].
2180    pub fn compaction_focus_instructions(mut self, text: impl Into<String>) -> Self {
2181        self.config.compaction_focus_instructions = Some(text.into());
2182        self
2183    }
2184
2185    /// P4b: turn on the auto-title gate — see [`Config::auto_title`].
2186    pub fn auto_title(mut self, enabled: bool) -> Self {
2187        self.config.auto_title = enabled;
2188        self
2189    }
2190
2191    /// P4b: set the mid-turn steering delivery mode — see
2192    /// [`Config::steering_mode`].
2193    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
2194        self.config.steering_mode = mode;
2195        self
2196    }
2197
2198    /// P4b: set the idle follow-up delivery mode — see
2199    /// [`Config::follow_up_mode`].
2200    pub fn follow_up_mode(mut self, mode: SteeringMode) -> Self {
2201        self.config.follow_up_mode = mode;
2202        self
2203    }
2204
2205    /// P4b: install a stop-gate hook — see [`Config::stop_gate`].
2206    pub fn stop_gate(mut self, hook: StopGateHook) -> Self {
2207        self.config.stop_gate = Some(hook);
2208        self
2209    }
2210
2211    /// P4c: turn on multimodal `read_file` — see [`Config::read_file_multimodal`].
2212    pub fn read_file_multimodal(mut self, enabled: bool) -> Self {
2213        self.config.read_file_multimodal = enabled;
2214        self
2215    }
2216
2217    /// P4c: require a prior read before `edit_file` accepts an edit — see
2218    /// [`Config::edit_file_require_read_before_edit`].
2219    pub fn edit_file_require_read_before_edit(mut self, enabled: bool) -> Self {
2220        self.config.edit_file_require_read_before_edit = enabled;
2221        self
2222    }
2223
2224    /// P4c: turn on notebook-cell-aware `edit_file` — see
2225    /// [`Config::edit_file_notebook_aware`].
2226    pub fn edit_file_notebook_aware(mut self, enabled: bool) -> Self {
2227        self.config.edit_file_notebook_aware = enabled;
2228        self
2229    }
2230
2231    /// P4c: turn on shell-environment snapshotting — see
2232    /// [`Config::shell_env_snapshot`].
2233    pub fn shell_env_snapshot(mut self, enabled: bool) -> Self {
2234        self.config.shell_env_snapshot = enabled;
2235        self
2236    }
2237
2238    /// P4c: set the doom-loop repetition threshold — see
2239    /// [`Config::doom_loop_threshold`].
2240    pub fn doom_loop_threshold(mut self, n: u32) -> Self {
2241        self.config.doom_loop_threshold = Some(n);
2242        self
2243    }
2244
2245    /// P4c: turn on on-demand nested instruction loading — see
2246    /// [`Config::nested_instructions`].
2247    pub fn nested_instructions(mut self, enabled: bool) -> Self {
2248        self.config.nested_instructions = enabled;
2249        self
2250    }
2251
2252    /// P4c: turn on mid-session model switch's persisted-record +
2253    /// reasoning-filter behavior — see [`Config::model_switch_allow_switch`].
2254    pub fn model_switch_allow_switch(mut self, enabled: bool) -> Self {
2255        self.config.model_switch_allow_switch = enabled;
2256        self
2257    }
2258
2259    /// P4e: turn on ambient context-injection blocks — see
2260    /// [`Config::context_injections`].
2261    pub fn context_injections(mut self, enabled: bool) -> Self {
2262        self.config.context_injections = enabled;
2263        self
2264    }
2265
2266    /// P4e: append one named ambient context block — see
2267    /// [`Config::context_injection_blocks`].
2268    pub fn context_injection_block(
2269        mut self,
2270        name: impl Into<String>,
2271        content: impl Into<String>,
2272    ) -> Self {
2273        self.config
2274            .context_injection_blocks
2275            .push(ContextInjectionBlock::new(name, content));
2276        self
2277    }
2278
2279    /// P4e: master gate for all auto-compaction — see
2280    /// [`Config::compaction_enabled`].
2281    pub fn compaction_enabled(mut self, enabled: bool) -> Self {
2282        self.config.compaction_enabled = enabled;
2283        self
2284    }
2285
2286    /// P4e: run independent tool calls concurrently — see
2287    /// [`Config::parallel_tool_calls`].
2288    pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
2289        self.config.parallel_tool_calls = enabled;
2290        self
2291    }
2292
2293    /// P4e: capture git branch/sha/dirty at construction — see
2294    /// [`Config::session_git_metadata`].
2295    pub fn session_git_metadata(mut self, enabled: bool) -> Self {
2296        self.config.session_git_metadata = enabled;
2297        self
2298    }
2299
2300    /// P4e DEFECT-FIX: ephemeral vs. persisted session gate — see
2301    /// [`Config::session_persist`].
2302    pub fn session_persist(mut self, enabled: bool) -> Self {
2303        self.config.session_persist = enabled;
2304        self
2305    }
2306
2307    /// P4e DEFECT-FIX: a caller-configured session name — see
2308    /// [`Config::session_name`].
2309    pub fn session_name(mut self, name: impl Into<String>) -> Self {
2310        self.config.session_name = Some(name.into());
2311        self
2312    }
2313
2314    /// P5-3 (§3.1 `capabilities.subagents.enabled`) — see
2315    /// [`Config::subagents_enabled`].
2316    pub fn subagents_enabled(mut self, enabled: bool) -> Self {
2317        self.config.subagents_enabled = enabled;
2318        self
2319    }
2320
2321    /// P5-3 (§3.1 `capabilities.subagents.max_depth`) — see
2322    /// [`Config::subagents_max_depth`].
2323    pub fn subagents_max_depth(mut self, n: usize) -> Self {
2324        self.config.subagents_max_depth = n;
2325        self
2326    }
2327
2328    /// P5-3 (resource bound) — see [`Config::subagents_max_concurrent`].
2329    pub fn subagents_max_concurrent(mut self, n: usize) -> Self {
2330        self.config.subagents_max_concurrent = n;
2331        self
2332    }
2333
2334    /// P5-3 (§3.1 `capabilities.subagents.background`) — see
2335    /// [`Config::subagents_background`].
2336    pub fn subagents_background(mut self, enabled: bool) -> Self {
2337        self.config.subagents_background = enabled;
2338        self
2339    }
2340
2341    /// P5-3 (§2.2 C6) — see [`Config::subagents_background_prompts`].
2342    pub fn subagents_background_prompts(
2343        mut self,
2344        policy: crate::subagents::BackgroundPromptsPolicy,
2345    ) -> Self {
2346        self.config.subagents_background_prompts = Some(policy);
2347        self
2348    }
2349
2350    /// Enable Claude Code's `Agent` compatibility alias for named subagents.
2351    pub fn subagents_claude_agent_alias(mut self, enabled: bool) -> Self {
2352        self.config.subagents_claude_agent_alias = enabled;
2353        self
2354    }
2355
2356    /// Enable Claude's paused runtime-state compatibility intrinsics.
2357    pub fn claude_runtime_tools_enabled(mut self, enabled: bool) -> Self {
2358        self.config.claude_runtime_tools_enabled = enabled;
2359        self
2360    }
2361
2362    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`) — register one
2363    /// named agent definition, keyed by [`crate::subagents::NamedAgentDefinition::name`].
2364    pub fn subagent_definition(mut self, def: crate::subagents::NamedAgentDefinition) -> Self {
2365        self.config
2366            .subagents_definitions
2367            .insert(def.name.clone(), def);
2368        self
2369    }
2370
2371    /// P5-3 (runtime-only) — see [`Config::subagent_depth`]. Not something
2372    /// an ordinary caller sets by hand; `Agent::run_spawn_subagent` sets it
2373    /// on the CHILD config it builds.
2374    pub fn subagent_depth(mut self, depth: usize) -> Self {
2375        self.config.subagent_depth = depth;
2376        self
2377    }
2378
2379    /// P5-6 (§3.1 `capabilities.tools_background.enabled`) — see
2380    /// [`Config::tools_background_enabled`].
2381    pub fn tools_background_enabled(mut self, enabled: bool) -> Self {
2382        self.config.tools_background_enabled = enabled;
2383        self
2384    }
2385
2386    /// P5-6 (resource bound) — see [`Config::tools_background_max_concurrent`].
2387    pub fn tools_background_max_concurrent(mut self, n: usize) -> Self {
2388        self.config.tools_background_max_concurrent = n;
2389        self
2390    }
2391
2392    /// P5-6 (resource bound) — see [`Config::tools_background_max_output_bytes`].
2393    pub fn tools_background_max_output_bytes(mut self, n: usize) -> Self {
2394        self.config.tools_background_max_output_bytes = n;
2395        self
2396    }
2397
2398    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`) — see
2399    /// [`Config::checkpoint_enabled`].
2400    pub fn checkpoint_enabled(mut self, enabled: bool) -> Self {
2401        self.config.checkpoint_enabled = enabled;
2402        self
2403    }
2404
2405    /// P5-9 (bounded-disk requirement) — see [`Config::checkpoint_retain`].
2406    pub fn checkpoint_retain(mut self, n: usize) -> Self {
2407        self.config.checkpoint_retain = n;
2408        self
2409    }
2410
2411    /// P5-9 (embedder/test override) — see [`Config::checkpoint_dir`].
2412    pub fn checkpoint_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2413        self.config.checkpoint_dir = Some(dir.into());
2414        self
2415    }
2416
2417    /// P5-11 (§3.1 `capabilities.lsp.enabled`) — see [`Config::lsp_enabled`].
2418    pub fn lsp_enabled(mut self, enabled: bool) -> Self {
2419        self.config.lsp_enabled = enabled;
2420        self
2421    }
2422
2423    /// P5-11 (`capabilities.lsp.servers`) — see [`Config::lsp_servers`].
2424    pub fn lsp_servers(mut self, servers: Vec<(String, crate::lsp::LspServerSpec)>) -> Self {
2425        self.config.lsp_servers = servers;
2426        self
2427    }
2428
2429    /// P5-11 (bounded-context requirement) — see [`Config::lsp_max_diagnostics`].
2430    pub fn lsp_max_diagnostics(mut self, n: usize) -> Self {
2431        self.config.lsp_max_diagnostics = n;
2432        self
2433    }
2434
2435    /// P5-11 (bounded-latency requirement) — see [`Config::lsp_timeout_secs`].
2436    pub fn lsp_timeout_secs(mut self, secs: u64) -> Self {
2437        self.config.lsp_timeout_secs = secs;
2438        self
2439    }
2440
2441    /// P5-11 (§3.1 `capabilities.formatters.enabled`) — see
2442    /// [`Config::formatters_enabled`].
2443    pub fn formatters_enabled(mut self, enabled: bool) -> Self {
2444        self.config.formatters_enabled = enabled;
2445        self
2446    }
2447
2448    /// P5-11 (`capabilities.formatters.<name>`) — see [`Config::formatters`].
2449    pub fn formatters(
2450        mut self,
2451        formatters: Vec<(String, crate::formatters::FormatterSpec)>,
2452    ) -> Self {
2453        self.config.formatters = formatters;
2454        self
2455    }
2456
2457    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10) — see
2458    /// [`Config::formatters_diff_back`].
2459    pub fn formatters_diff_back(mut self, diff_back: bool) -> Self {
2460        self.config.formatters_diff_back = diff_back;
2461        self
2462    }
2463
2464    /// P5-11 (bounded-latency requirement) — see [`Config::formatters_timeout_secs`].
2465    pub fn formatters_timeout_secs(mut self, secs: u64) -> Self {
2466        self.config.formatters_timeout_secs = secs;
2467        self
2468    }
2469
2470    /// P5-12 (§3.1 `capabilities.trust.enabled`) — see [`Config::trust_enabled`].
2471    pub fn trust_enabled(mut self, enabled: bool) -> Self {
2472        self.config.trust_enabled = enabled;
2473        self
2474    }
2475
2476    /// P5-12 (`capabilities.trust.default`) — see [`Config::trust_default`].
2477    pub fn trust_default(mut self, default: crate::plugins::TrustDecision) -> Self {
2478        self.config.trust_default = default;
2479        self
2480    }
2481
2482    /// P5-12 (§3.1 `capabilities.plugins.enabled`) — see [`Config::plugins_enabled`].
2483    pub fn plugins_enabled(mut self, enabled: bool) -> Self {
2484        self.config.plugins_enabled = enabled;
2485        self
2486    }
2487
2488    /// P5-12 (`capabilities.plugins.dirs`) — see [`Config::plugins_dirs`].
2489    pub fn plugins_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
2490        self.config.plugins_dirs = dirs;
2491        self
2492    }
2493
2494    /// Finalize the configuration.
2495    pub fn build(self) -> Config {
2496        self.config
2497    }
2498}
2499
2500#[cfg(test)]
2501mod tests {
2502    use super::*;
2503
2504    /// Every NEW `ConfigProfile` field the P1 migration adds (design
2505    /// §5.2/§3.2's explicit unblock list) actually reaches the built
2506    /// `Config` through `apply_profile`.
2507    #[test]
2508    fn apply_profile_applies_every_new_p1_field() {
2509        let mut tool_overrides = HashMap::new();
2510        tool_overrides.insert(
2511            "write_file".to_string(),
2512            ToolOverrideProfile {
2513                enabled: Some(false),
2514                description: Some("custom".to_string()),
2515                schema_tier: Some("minimal".to_string()),
2516                timeout_secs: None,
2517            },
2518        );
2519        let mut extra_headers = HashMap::new();
2520        extra_headers.insert("X-Title".to_string(), "supercode".to_string());
2521        let mut extra_body = serde_json::Map::new();
2522        extra_body.insert("provider_flag".to_string(), serde_json::json!(true));
2523        let mut prompts = HashMap::new();
2524        prompts.insert("standup".to_string(), "Summarize {args}".to_string());
2525
2526        let profile = ConfigProfile {
2527            api_key_env: Some("MY_KEY".to_string()),
2528            max_iterations: Some(40),
2529            additional_dirs: Some(vec!["../sibling".to_string()]),
2530            compact_after_messages: Some(50),
2531            cache_plan: Some("imported_prefix".to_string()),
2532            tool_advertising: Some("deferred".to_string()),
2533            tool_advertising_core: Some(vec!["bash".to_string()]),
2534            schema_tier: Some("medium".to_string()),
2535            auto_approved_tools: Some(vec!["read_file".to_string()]),
2536            extra_headers: Some(extra_headers),
2537            extra_body: Some(extra_body),
2538            max_tool_output_bytes: Some(4096),
2539            max_total_output_tokens: Some(8192),
2540            prompts: Some(prompts),
2541            tool_overrides: Some(tool_overrides),
2542            ..Default::default()
2543        };
2544
2545        let config = ConfigBuilder::default().apply_profile(&profile).build();
2546
2547        assert_eq!(config.api_key_env, "MY_KEY");
2548        assert_eq!(config.max_iterations, 40);
2549        assert_eq!(
2550            config.additional_dirs,
2551            vec![std::path::PathBuf::from("../sibling")]
2552        );
2553        assert_eq!(config.compact_after_messages, Some(50));
2554        assert_eq!(config.cache_plan, CachePlan::ImportedPrefix);
2555        match &config.tool_advertising {
2556            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
2557            ToolAdvertising::Full => panic!("expected Deferred"),
2558        }
2559        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Medium);
2560        assert!(config.auto_approved_tools.contains("read_file"));
2561        assert_eq!(
2562            config.extra_headers.get("X-Title").map(String::as_str),
2563            Some("supercode")
2564        );
2565        assert_eq!(
2566            config.extra_body.get("provider_flag"),
2567            Some(&serde_json::json!(true))
2568        );
2569        assert_eq!(config.max_tool_output_bytes, Some(4096));
2570        assert_eq!(config.max_total_output_tokens, Some(8192));
2571        assert_eq!(
2572            config.prompts.get("standup").map(String::as_str),
2573            Some("Summarize {args}")
2574        );
2575        assert!(!config.tool_enabled("write_file"));
2576        assert_eq!(config.tool_description("write_file", "builtin"), "custom");
2577        assert_eq!(
2578            config.schema_tier_for("write_file"),
2579            crate::tools::SchemaTier::Minimal
2580        );
2581        // Built-in prompts survive — `prompts` is a table merge, not a
2582        // wholesale replace (§3.3).
2583        assert!(config.prompts.contains_key("code-review"));
2584    }
2585
2586    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): additive, composed
2587    /// onto the DEFAULT system prompt when no `system_prompt` override is
2588    /// set — distinct from replacing it.
2589    #[test]
2590    fn apply_profile_append_system_prompt_composes_onto_the_default() {
2591        let profile = ConfigProfile {
2592            append_system_prompt: Some("Always run tests before committing.".to_string()),
2593            ..Default::default()
2594        };
2595        let config = ConfigBuilder::default().apply_profile(&profile).build();
2596        assert_eq!(
2597            config.system_prompt,
2598            format!("{DEFAULT_SYSTEM_PROMPT}\n\nAlways run tests before committing.")
2599        );
2600    }
2601
2602    /// Composed onto an EXPLICIT `system_prompt` override in the SAME
2603    /// profile, not the default — replacement then append, in that order.
2604    #[test]
2605    fn apply_profile_append_system_prompt_composes_onto_an_explicit_override() {
2606        let profile = ConfigProfile {
2607            system_prompt: Some("You are terse.".to_string()),
2608            append_system_prompt: Some("Always run tests before committing.".to_string()),
2609            ..Default::default()
2610        };
2611        let config = ConfigBuilder::default().apply_profile(&profile).build();
2612        assert_eq!(
2613            config.system_prompt,
2614            "You are terse.\n\nAlways run tests before committing."
2615        );
2616    }
2617
2618    /// Default-off: no `append_system_prompt` set leaves `system_prompt`
2619    /// completely untouched (byte-identical to today's behavior).
2620    #[test]
2621    fn apply_profile_no_append_system_prompt_leaves_system_prompt_untouched() {
2622        let profile = ConfigProfile {
2623            system_prompt: Some("You are terse.".to_string()),
2624            ..Default::default()
2625        };
2626        let config = ConfigBuilder::default().apply_profile(&profile).build();
2627        assert_eq!(config.system_prompt, "You are terse.");
2628    }
2629
2630    /// Unknown enum strings fail SAFE (existing precedent, config.rs
2631    /// `sandbox`/`approval` parsing) — extended to the two NEW enum-shaped
2632    /// fields this migration adds.
2633    #[test]
2634    fn apply_profile_fails_safe_on_unknown_new_enums() {
2635        let profile = ConfigProfile {
2636            cache_plan: Some("bogus".to_string()),
2637            schema_tier: Some("bogus".to_string()),
2638            tool_advertising: Some("bogus".to_string()),
2639            ..Default::default()
2640        };
2641        let config = ConfigBuilder::default().apply_profile(&profile).build();
2642        assert_eq!(config.cache_plan, CachePlan::Off);
2643        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Full);
2644        // F5 fix: this was a bare `matches!(...)` with no `assert!` around
2645        // it, so the expression's bool result was silently discarded — the
2646        // fail-safe behavior it names was never actually checked.
2647        assert!(matches!(config.tool_advertising, ToolAdvertising::Full));
2648    }
2649
2650    /// F6: setting only `tool_advertising_core` (mode absent) must not
2651    /// silently reset the mode to `Full`, discarding the array — and
2652    /// setting `tool_advertising = "deferred"` with no `_core` must not wipe
2653    /// an already-set core list. Each key replaces independently (§3.3).
2654    #[test]
2655    fn apply_profile_tool_advertising_mode_and_core_replace_independently() {
2656        // Only `_core` set on top of an already-`Deferred` config: the mode
2657        // must stay `Deferred`, with the NEW core list — not reset to
2658        // `Full` (the pre-fix bug).
2659        let builder = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
2660            core: vec!["bash".to_string()],
2661        });
2662        let profile = ConfigProfile {
2663            tool_advertising_core: Some(vec!["read_file".to_string(), "bash".to_string()]),
2664            ..Default::default()
2665        };
2666        let config = builder.apply_profile(&profile).build();
2667        match &config.tool_advertising {
2668            ToolAdvertising::Deferred { core } => {
2669                assert_eq!(core, &vec!["read_file".to_string(), "bash".to_string()])
2670            }
2671            ToolAdvertising::Full => panic!("mode must not reset to Full when only _core is set"),
2672        }
2673
2674        // Mode = "deferred" set with no `_core`: must keep the existing
2675        // core list, not wipe it to empty.
2676        let builder2 = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
2677            core: vec!["bash".to_string()],
2678        });
2679        let profile2 = ConfigProfile {
2680            tool_advertising: Some("deferred".to_string()),
2681            ..Default::default()
2682        };
2683        let config2 = builder2.apply_profile(&profile2).build();
2684        match &config2.tool_advertising {
2685            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
2686            ToolAdvertising::Full => panic!("expected Deferred to survive"),
2687        }
2688    }
2689
2690    /// Tables merge key-wise (§3.3): applying a profile with one
2691    /// `tool_overrides` entry must not blow away a different tool's
2692    /// override already on the builder.
2693    #[test]
2694    fn apply_profile_merges_tool_overrides_key_wise() {
2695        let builder = ConfigBuilder::default().disable_tool("bash");
2696        let mut overrides = HashMap::new();
2697        overrides.insert(
2698            "read_file".to_string(),
2699            ToolOverrideProfile {
2700                enabled: Some(false),
2701                description: None,
2702                schema_tier: None,
2703                timeout_secs: None,
2704            },
2705        );
2706        let profile = ConfigProfile {
2707            tool_overrides: Some(overrides),
2708            ..Default::default()
2709        };
2710        let config = builder.apply_profile(&profile).build();
2711        assert!(!config.tool_enabled("bash"));
2712        assert!(!config.tool_enabled("read_file"));
2713    }
2714
2715    // -----------------------------------------------------------------
2716    // P4: deny-rule PATTERNS generalizing auto_approved_tools (§5.2 "P4").
2717    // -----------------------------------------------------------------
2718
2719    #[test]
2720    fn glob_match_exact_and_wildcard_forms() {
2721        assert!(glob_match("bash", "bash"));
2722        assert!(!glob_match("bash", "bash2"));
2723        assert!(glob_match("bash*", "bash"));
2724        assert!(glob_match("bash*", "bash_tool"));
2725        assert!(!glob_match("bash*", "not_bash"));
2726        assert!(glob_match("*_write", "edit_write"));
2727        assert!(!glob_match("*_write", "write_edit"));
2728        assert!(glob_match("mcp__*__search", "mcp__github__search"));
2729        assert!(glob_match("*", "anything at all"));
2730        assert!(glob_match("*", ""));
2731        assert!(glob_match("", ""));
2732        assert!(!glob_match("", "x"));
2733        // Multiple `*`s in one pattern (the iterative two-pointer rewrite's
2734        // main new surface area vs. the old single-recursion-site matcher).
2735        assert!(glob_match("*a*a*a*", "aaaa"));
2736        assert!(glob_match("*a*b*c*", "xaxbxcx"));
2737        assert!(!glob_match("*a*b*c*", "xbxax"));
2738        assert!(glob_match("a*b*c", "aXbXc"));
2739        assert!(glob_match("a*b*c", "abc"));
2740        assert!(!glob_match("a*b*c", "acb"));
2741    }
2742
2743    /// LOW-2 (Fable-5 P4a review): `tool_deny_patterns`/`tool_allow_patterns`
2744    /// can be project-controlled (a project may only ADD to `rules.deny`,
2745    /// never replace it — see `configfile::merge_permissions_capability` —
2746    /// but an ADDED pattern is still attacker-chosen content), so a crafted
2747    /// pattern must not be able to make `glob_match` itself a self-DoS on
2748    /// every tool call. The old naive recursive matcher
2749    /// (`Some(b'*') => inner(&p[1..], t) || (!t.is_empty() &&
2750    /// inner(p, &t[1..]))`) backtracks exponentially on a pattern with many
2751    /// `*`s against a text with no matching suffix; this proves the
2752    /// iterative rewrite returns promptly on exactly that shape.
2753    #[test]
2754    fn glob_match_pathological_pattern_returns_promptly() {
2755        let pattern = "*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*b";
2756        let text = "a".repeat(40);
2757        let start = std::time::Instant::now();
2758        let result = glob_match(pattern, &text);
2759        let elapsed = start.elapsed();
2760        assert!(!result, "text has no trailing 'b', so this must not match");
2761        assert!(
2762            elapsed < std::time::Duration::from_millis(200),
2763            "glob_match took {elapsed:?} on a pathological pattern — exponential backtracking regressed"
2764        );
2765    }
2766
2767    /// Default-off: empty deny/allow patterns leave `needs_approval`
2768    /// byte-identical to pre-P4 behavior (the existing `auto_approved_tools`
2769    /// contract, unaffected).
2770    #[test]
2771    fn needs_approval_default_unaffected_by_empty_patterns() {
2772        let config = Config::builder()
2773            .approval(ApprovalPolicy::OnRequest)
2774            .auto_approve_tool("read_file")
2775            .build();
2776        assert!(config.tool_deny_patterns.is_empty());
2777        assert!(config.tool_allow_patterns.is_empty());
2778        assert!(!config.needs_approval("read_file"));
2779        assert!(config.needs_approval("bash"));
2780    }
2781
2782    /// Happy path: a deny pattern forces approval even under
2783    /// `ApprovalPolicy::Never` — the entire point of a deny rule is a hard
2784    /// floor `--yes`/`Never` can't bypass.
2785    #[test]
2786    fn needs_approval_deny_pattern_forces_approval_even_under_never() {
2787        let config = Config::builder()
2788            .approval(ApprovalPolicy::Never)
2789            .deny_tool_pattern("bash*")
2790            .build();
2791        assert!(config.needs_approval("bash"));
2792        assert!(config.needs_approval("bash_tool"));
2793        // A non-matching tool is unaffected — still `Never`.
2794        assert!(!config.needs_approval("read_file"));
2795    }
2796
2797    /// Happy path: an allow pattern exempts a matching tool from approval
2798    /// under `OnRequest`, exactly like an exact `auto_approved_tools` entry.
2799    #[test]
2800    fn needs_approval_allow_pattern_exempts_under_on_request() {
2801        let config = Config::builder()
2802            .approval(ApprovalPolicy::OnRequest)
2803            .allow_tool_pattern("read_*")
2804            .build();
2805        assert!(!config.needs_approval("read_file"));
2806        assert!(!config.needs_approval("read_dir"));
2807        assert!(config.needs_approval("bash"));
2808    }
2809
2810    /// Deny wins over allow when a tool matches both — deny is checked
2811    /// first and returns unconditionally.
2812    #[test]
2813    fn needs_approval_deny_wins_over_allow_on_the_same_tool() {
2814        let config = Config::builder()
2815            .approval(ApprovalPolicy::OnRequest)
2816            .allow_tool_pattern("bash*")
2817            .deny_tool_pattern("bash*")
2818            .build();
2819        assert!(config.needs_approval("bash"));
2820    }
2821
2822    /// An allow pattern never exempts anything under `Untrusted` — same
2823    /// scoping `auto_approved_tools` already has (only consulted under
2824    /// `OnRequest`).
2825    #[test]
2826    fn needs_approval_allow_pattern_never_exempts_under_untrusted() {
2827        let config = Config::builder()
2828            .approval(ApprovalPolicy::Untrusted)
2829            .allow_tool_pattern("*")
2830            .build();
2831        assert!(config.needs_approval("read_file"));
2832    }
2833
2834    // ---- P4b: default-off / unchanged-unless-set for every new field -----
2835
2836    #[test]
2837    fn p4b_defaults_are_byte_identical_to_pre_p4b_behavior() {
2838        let config = Config::default();
2839        assert!(!config.env_context);
2840        assert_eq!(config.project_root_markers, vec![".git".to_string()]);
2841        assert_eq!(config.project_doc_max_bytes, None);
2842        assert!(!config.instruction_imports);
2843        // retry_enabled defaults TRUE (matches the pre-existing always-on
2844        // transport retry — see `provider::HttpOptions::from_retry_config`),
2845        // but the override knobs default unset, so the transport sees its
2846        // own untouched built-in defaults.
2847        assert!(config.retry_enabled);
2848        assert_eq!(config.retry_max_retries, None);
2849        assert_eq!(config.retry_base_delay_ms, None);
2850        assert_eq!(config.compaction_reserve_tokens, None);
2851        assert_eq!(config.compaction_keep_recent_tokens, None);
2852        assert_eq!(config.compaction_focus_instructions, None);
2853        assert!(!config.auto_title);
2854        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
2855        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
2856        assert!(config.stop_gate.is_none());
2857    }
2858
2859    #[test]
2860    fn apply_profile_applies_every_new_p4b_field() {
2861        let profile = ConfigProfile {
2862            env_context: Some(true),
2863            project_root_markers: Some(vec![".hg".to_string()]),
2864            project_doc_max_bytes: Some(16_384),
2865            instruction_imports: Some(true),
2866            retry_enabled: Some(false),
2867            retry_max_retries: Some(9),
2868            retry_base_delay_ms: Some(750),
2869            compaction_reserve_tokens: Some(8_000),
2870            compaction_keep_recent_tokens: Some(12_000),
2871            compaction_focus_instructions: Some("keep fixing the auth bug".to_string()),
2872            auto_title: Some(true),
2873            steering_mode: Some("all".to_string()),
2874            follow_up_mode: Some("one-at-a-time".to_string()),
2875            ..Default::default()
2876        };
2877        let config = ConfigBuilder::default().apply_profile(&profile).build();
2878        assert!(config.env_context);
2879        assert_eq!(config.project_root_markers, vec![".hg".to_string()]);
2880        assert_eq!(config.project_doc_max_bytes, Some(16_384));
2881        assert!(config.instruction_imports);
2882        assert!(!config.retry_enabled);
2883        assert_eq!(config.retry_max_retries, Some(9));
2884        assert_eq!(config.retry_base_delay_ms, Some(750));
2885        assert_eq!(config.compaction_reserve_tokens, Some(8_000));
2886        assert_eq!(config.compaction_keep_recent_tokens, Some(12_000));
2887        assert_eq!(
2888            config.compaction_focus_instructions.as_deref(),
2889            Some("keep fixing the auth bug")
2890        );
2891        assert!(config.auto_title);
2892        assert_eq!(config.steering_mode, SteeringMode::All);
2893        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
2894    }
2895
2896    #[test]
2897    fn apply_profile_unrecognized_steering_mode_is_ignored_not_defaulted_wrongly() {
2898        let profile = ConfigProfile {
2899            steering_mode: Some("bogus".to_string()),
2900            ..Default::default()
2901        };
2902        let config = ConfigBuilder::default().apply_profile(&profile).build();
2903        // Fail safe: an unrecognized value leaves the built-in default in
2904        // place rather than panicking or guessing.
2905        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
2906    }
2907
2908    // ---- P4c: default-off / unchanged-unless-set for every new field -----
2909
2910    #[test]
2911    fn p4c_defaults_are_byte_identical_to_pre_p4c_behavior() {
2912        let config = Config::default();
2913        assert!(!config.read_file_multimodal);
2914        assert!(!config.edit_file_require_read_before_edit);
2915        assert!(!config.edit_file_notebook_aware);
2916        assert!(!config.shell_env_snapshot);
2917        assert_eq!(config.doom_loop_threshold, None);
2918        assert!(!config.nested_instructions);
2919        assert!(!config.model_switch_allow_switch);
2920    }
2921
2922    #[test]
2923    fn apply_profile_applies_every_new_p4c_field() {
2924        let profile = ConfigProfile {
2925            read_file_multimodal: Some(true),
2926            edit_file_require_read_before_edit: Some(true),
2927            edit_file_notebook_aware: Some(true),
2928            shell_env_snapshot: Some(true),
2929            doom_loop_threshold: Some(3),
2930            nested_instructions: Some(true),
2931            model_switch_allow_switch: Some(true),
2932            ..Default::default()
2933        };
2934        let config = ConfigBuilder::default().apply_profile(&profile).build();
2935        assert!(config.read_file_multimodal);
2936        assert!(config.edit_file_require_read_before_edit);
2937        assert!(config.edit_file_notebook_aware);
2938        assert!(config.shell_env_snapshot);
2939        assert_eq!(config.doom_loop_threshold, Some(3));
2940        assert!(config.nested_instructions);
2941        assert!(config.model_switch_allow_switch);
2942    }
2943
2944    #[test]
2945    fn builder_methods_set_every_new_p4c_field() {
2946        let config = Config::builder()
2947            .read_file_multimodal(true)
2948            .edit_file_require_read_before_edit(true)
2949            .edit_file_notebook_aware(true)
2950            .shell_env_snapshot(true)
2951            .doom_loop_threshold(5)
2952            .nested_instructions(true)
2953            .model_switch_allow_switch(true)
2954            .build();
2955        assert!(config.read_file_multimodal);
2956        assert!(config.edit_file_require_read_before_edit);
2957        assert!(config.edit_file_notebook_aware);
2958        assert!(config.shell_env_snapshot);
2959        assert_eq!(config.doom_loop_threshold, Some(5));
2960        assert!(config.nested_instructions);
2961        assert!(config.model_switch_allow_switch);
2962    }
2963}