Skip to main content

supercode_harness/
agent.rs

1//! The agent loop.
2
3use std::collections::HashSet;
4
5use crate::config::{CachePlan, Config, SteeringMode, ToolAdvertising};
6use crate::error::{Error, Result};
7use crate::event::AgentEvent;
8use crate::message::{ChatMessage, Role};
9use crate::provider::{self, ChatRequest, OpenAiProvider, Provider, ToolSchema};
10use crate::reduce::rehydrate::CAP_NOTICE_MARKER;
11use crate::reduce::{self, ReductionLog, ReductionPolicy};
12use crate::session::Session;
13use crate::sidecar::SidecarWriter;
14use crate::tools::{ToolContext, ToolRegistry};
15
16/// BP-6: how many skill bodies one user message may pull in through `$slug`
17/// mentions — Claude Code caps skill chaining at six per message (cc§7
18/// "Skill chaining"); the same ceiling bounds the mention path here.
19const MAX_SKILL_LOADS_PER_MESSAGE: usize = 6;
20
21/// BP-5: how many `@path` mentions one message may attach. A prompt is not
22/// a bulk loader; past this the user means `--file`.
23const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 10;
24
25/// BP-5: ceiling on the bytes one `@path` mention contributes.
26const MAX_FILE_MENTION_BYTES: usize = 64 * 1024;
27
28/// Tool name of the `tool_search` agent intrinsic (B6). Never a registered
29/// [`crate::tools::Tool`] — intercepted in [`Agent::run_tool`] before registry
30/// lookup, so it works under any [`ToolAdvertising`] mode.
31const TOOL_SEARCH: &str = "tool_search";
32
33/// Tool name of the `expand_reduction` agent intrinsic (T12/TR-1) — the
34/// model-invocable rehydration counterpart to `tool_search`, same
35/// interception pattern. Advertised whenever a [`ReductionPolicy`] is
36/// installed, regardless of [`ToolAdvertising`] mode (see [`Self::tool_schemas`]).
37const EXPAND_REDUCTION: &str = "expand_reduction";
38
39/// Tool name of the `sidecar_search` agent intrinsic (T12/TR-1).
40const SIDECAR_SEARCH: &str = "sidecar_search";
41
42/// Tool name of the `spawn_subagent` agent intrinsic (P5-3, §2 module 9 D1
43/// "spawn tool"). Same interception pattern as [`TOOL_SEARCH`] — never a
44/// registered [`crate::tools::Tool`], intercepted in [`Agent::run_tool`]
45/// before registry lookup — but ALSO needs full `&mut self` async access
46/// (running a whole child agent loop, or `tokio::spawn`-ing one), which
47/// [`Agent::prepare_tool_call`]'s purely-synchronous intrinsics don't, so
48/// the interception point is `Self::run_tool`'s top, not
49/// `prepare_tool_call`.
50const SPAWN_SUBAGENT: &str = "spawn_subagent";
51
52/// BP-7 (catalog §4a "Review mode"): the `[core.prompts]` key the review
53/// turn's template lives under. One name for both presets — cc spells the
54/// command `/code-review`, cx spells it `/review`, and both resolve to this
55/// template, so the row's evidence is one config key, not two.
56pub const REVIEW_PROMPT_NAME: &str = "code-review";
57
58/// BP-7 (catalog §4a "Side/ephemeral Q&A"): the instruction prefixed to a
59/// side question, so the model knows it is answering ABOUT the session
60/// rather than continuing it. The exchange never enters history either way;
61/// this keeps the answer from reading like the next assistant turn.
62const SIDE_QUESTION_PREAMBLE: &str = "[side question — answer from the conversation above; this exchange is not part of the conversation and you have no tools for it]";
63
64/// Claude Code's native name for [`SPAWN_SUBAGENT`]. It is exposed only when
65/// `Config::subagents_claude_agent_alias` is enabled for a Claude import.
66const CLAUDE_AGENT: &str = "Agent";
67
68/// Claude Code spellings for core filesystem/shell tools. Imported Claude
69/// context frequently continues to call these names even when another model
70/// is driving the turn, so emulation must translate execution as well as
71/// preserve the original call/result names in the transcript.
72const CLAUDE_BASH: &str = "Bash";
73const CLAUDE_READ: &str = "Read";
74const CLAUDE_WRITE: &str = "Write";
75const CLAUDE_EDIT: &str = "Edit";
76const CLAUDE_GLOB: &str = "Glob";
77const CLAUDE_GREP: &str = "Grep";
78
79/// Claude Code scheduler compatibility intrinsics. They edit an imported
80/// [`crate::ClaudeRuntimeManifest`]; actual timer execution belongs to an
81/// embedding scheduler driver, never this agent loop.
82const CLAUDE_CRON_CREATE: &str = "CronCreate";
83const CLAUDE_CRON_DELETE: &str = "CronDelete";
84const CLAUDE_CRON_LIST: &str = "CronList";
85const CLAUDE_SCHEDULE_WAKEUP: &str = "ScheduleWakeup";
86
87/// Shared SDK steering mailbox. `accepting` and `queue` share one lock so a
88/// turn's final boundary can close acceptance atomically with its last drain;
89/// a steer can therefore never be acknowledged into the following turn.
90#[derive(Default)]
91pub(crate) struct SteerInbox {
92    queue: std::collections::VecDeque<QueuedSteer>,
93    accepting: bool,
94}
95
96struct QueuedSteer {
97    message: String,
98    sdk_bound: bool,
99}
100
101impl SteerInbox {
102    pub(crate) fn open(&mut self) {
103        self.queue.clear();
104        self.accepting = true;
105    }
106
107    pub(crate) fn enqueue(&mut self, message: String) -> bool {
108        if !self.accepting {
109            return false;
110        }
111        self.queue.push_back(QueuedSteer {
112            message,
113            sdk_bound: true,
114        });
115        true
116    }
117
118    pub(crate) fn close(&mut self) {
119        self.accepting = false;
120        self.queue.retain(|queued| !queued.sdk_bound);
121    }
122
123    fn drain(&mut self, mode: SteeringMode) -> Option<String> {
124        if self.queue.is_empty() {
125            return None;
126        }
127        match mode {
128            SteeringMode::All => Some(
129                self.queue
130                    .drain(..)
131                    .map(|queued| queued.message)
132                    .collect::<Vec<_>>()
133                    .join("\n\n"),
134            ),
135            SteeringMode::OneAtATime => self.queue.pop_front().map(|queued| queued.message),
136        }
137    }
138
139    fn drain_or_close(&mut self, mode: SteeringMode) -> Option<String> {
140        if !self.queue.iter().any(|queued| queued.sdk_bound) {
141            self.accepting = false;
142            return None;
143        }
144        self.drain(mode)
145    }
146
147    fn queue_unchecked(&mut self, message: String) {
148        self.queue.push_back(QueuedSteer {
149            message,
150            sdk_bound: false,
151        });
152    }
153
154    fn len(&self) -> usize {
155        self.queue.len()
156    }
157}
158
159struct SteerTurnGuard {
160    inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
161}
162
163impl SteerTurnGuard {
164    fn new(inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>) -> Self {
165        inbox
166            .lock()
167            .unwrap_or_else(std::sync::PoisonError::into_inner)
168            .accepting = true;
169        Self { inbox }
170    }
171}
172
173impl Drop for SteerTurnGuard {
174    fn drop(&mut self) {
175        self.inbox
176            .lock()
177            .unwrap_or_else(std::sync::PoisonError::into_inner)
178            .close();
179    }
180}
181
182/// Tool name of the `subagent_status` agent intrinsic (P5-3, D3
183/// "background+resume"): poll (and reap, once finished) a background child
184/// spawned via [`SPAWN_SUBAGENT`]. Only advertised when
185/// `Config::subagents_background` is on (see [`Agent::tool_schemas`]).
186const SUBAGENT_STATUS: &str = "subagent_status";
187
188/// BP-7 (catalog §4a "Background subagents + resume": cc's `SendMessage`,
189/// cx's v2 mailbox `send_message`): deliver a message to a STILL-RUNNING
190/// background child. Only advertised when `Config::subagents_background`
191/// is on.
192const SUBAGENT_MESSAGE: &str = "subagent_message";
193
194/// BP-7 (catalog §4a "Background subagents + resume": "resumable with
195/// context intact"): continue a FINISHED child with its own transcript
196/// restored, rather than starting a fresh one that has to be re-briefed.
197const SUBAGENT_RESUME: &str = "subagent_resume";
198
199/// Tool name of the `background_exec` agent intrinsic (P5-6, §2 module 4
200/// `tools.background` D1 "background exec"). Unlike [`SPAWN_SUBAGENT`], this
201/// needs no async child-agent loop — spawning a process
202/// (`tokio::process::Command::spawn`) is itself synchronous — so, like
203/// [`TOOL_SEARCH`], it is intercepted in [`Agent::prepare_tool_call`], not
204/// [`Agent::run_tool`].
205const BACKGROUND_EXEC: &str = "background_exec";
206
207/// Tool name of the `background_status` agent intrinsic (P5-6, D1 "monitor/
208/// event feed"): poll a background job's run status, drain its newly
209/// captured output as an [`AgentEvent::BackgroundOutput`] event, and reap it
210/// (remove it from [`Agent::background_jobs`]) once it has exited or been
211/// killed.
212const BACKGROUND_STATUS: &str = "background_status";
213
214/// Tool name of the `background_list` agent intrinsic (P5-6, D10
215/// "bg-manager"): list every background job this agent is currently
216/// tracking (running or finished-but-unreaped), without draining output or
217/// reaping anything.
218const BACKGROUND_LIST: &str = "background_list";
219
220/// Tool name of the `background_kill` agent intrinsic (P5-6, D10
221/// "bg-manager"): kill a background job's real OS process
222/// (`tokio::process::Child::start_kill`) and reap it immediately.
223const BACKGROUND_KILL: &str = "background_kill";
224
225/// P4e (§3.1 `core.parallel_tool_calls`): the synchronous outcome of
226/// [`Agent::prepare_tool_call`] — either a result already in hand (an
227/// intrinsic, or a call refused before it ever reached `Tool::execute`), or
228/// a plain registry-tool call ready for the (possibly concurrent) async
229/// `execute()` step.
230enum PreparedCall {
231    /// A final `(output, is_error)` result — no `Tool::execute` call is
232    /// coming for this one.
233    Done((String, bool)),
234    /// Passed every synchronous check; `execute(args, &ctx)` on the named
235    /// registry tool is the only remaining step.
236    Ready {
237        name: String,
238        args: serde_json::Value,
239    },
240}
241
242/// Marker prefix of the notice [`Agent::cap_tool_output`] appends to an
243/// oversized tool result kept in `history` (the recorder receives the full
244/// UX-26 (B7-warn): current wall-clock time as unix milliseconds, the same
245/// unit [`crate::sidecar::rfc3339_to_ms`] parses session timestamps into —
246/// lets [`Agent::build_request_messages`] compare "now" against a
247/// cross-process signal (a loaded session's last message timestamp) on
248/// equal footing with an in-process one (this agent's own last annotated
249/// send). Saturates to 0 on a pre-epoch clock rather than panicking (never
250/// happens on real hardware, but `duration_since` can theoretically error).
251fn now_ms() -> i64 {
252    std::time::SystemTime::now()
253        .duration_since(std::time::UNIX_EPOCH)
254        .map(|d| d.as_millis() as i64)
255        .unwrap_or(0)
256}
257
258/// P5-3: process-wide sequence number backing [`next_subagent_id`] —
259/// disambiguates two spawns landing in the same millisecond (which
260/// `now_ms()` alone cannot).
261static SUBAGENT_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
262
263/// P5-3: a fresh, process-unique child agent id (`"agent-<hex-ts>-<hex-seq>"`
264/// — the native analog of Claude Code's `agent-<id>` naming, see
265/// `crate::session::SessionMeta::agent_id`'s doc comment).
266fn next_subagent_id() -> String {
267    let seq = SUBAGENT_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
268    format!("agent-{:x}-{:x}", now_ms(), seq)
269}
270
271/// P5-4: the shape [`Agent::child_approval_handler_factory`]/
272/// [`Agent::set_child_approval_handler_factory`] share — factored into its
273/// own alias (clippy `type_complexity`) rather than spelled out inline at
274/// both use sites.
275type ChildApprovalHandlerFactory = dyn Fn(
276        String,
277        std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
278    ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
279    + Send
280    + Sync;
281
282/// BP-4 (catalog:109 "Context-usage introspection"): the live
283/// context-window accounting [`Agent::context_usage`] reports — cc's
284/// `/context` grid and cx's `/status` + `get_context_remaining` in one
285/// shape, over the numbers `resume --dry-run`'s preflight already computes.
286///
287/// Every token figure is the SAME estimate the context guard enforces
288/// (`crate::tokens`), so what this reports and what refuses an oversized
289/// turn can never disagree.
290#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
291pub struct ContextUsage {
292    /// The model the accounting is against.
293    pub model: String,
294    /// Messages in the projected request view (reduction stubs included).
295    pub messages: usize,
296    /// Estimated tokens for those messages.
297    pub message_tokens: u64,
298    /// Tools advertised on the next request.
299    pub tool_count: usize,
300    /// Estimated tokens for the serialized tool-schema array — a real part
301    /// of the wire request, and the half a message-only count misses.
302    pub tool_schema_tokens: u64,
303    /// `message_tokens + tool_schema_tokens`.
304    pub request_tokens: u64,
305    /// `request_tokens` with the guard's safety margin applied — the figure
306    /// the context guard actually compares.
307    pub projected_tokens: u64,
308    /// Headroom the guard reserves for the model's own reply.
309    pub response_reserve_tokens: u64,
310    /// The model's context window, when known.
311    pub context_limit: Option<u64>,
312    /// Tokens still available after the reply reserve, `0` when unknown.
313    pub remaining_tokens: u64,
314    /// `projected_tokens` as a whole percentage of the window (rounded),
315    /// `0` when the window is unknown. An integer so this whole struct
316    /// stays `Eq`-comparable on the frontend wire.
317    pub used_pct: u32,
318    /// Whether the next request would pass the context guard.
319    pub fits: bool,
320}
321
322impl ContextUsage {
323    /// One human line, the shape a `/context` command prints.
324    pub fn summary_line(&self) -> String {
325        match self.context_limit {
326            Some(limit) => format!(
327                "{} · {}% of {} used ({} projected, {} left) · {} messages {} · {} tool schemas {}",
328                self.model,
329                self.used_pct,
330                crate::tokens::fmt_approx_tokens(limit),
331                crate::tokens::fmt_approx_tokens(self.projected_tokens),
332                crate::tokens::fmt_approx_tokens(self.remaining_tokens),
333                self.messages,
334                crate::tokens::fmt_approx_tokens(self.message_tokens),
335                self.tool_count,
336                crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
337            ),
338            None => format!(
339                "{} · context window unknown · {} projected · {} messages {} · {} tool schemas {}",
340                self.model,
341                crate::tokens::fmt_approx_tokens(self.projected_tokens),
342                self.messages,
343                crate::tokens::fmt_approx_tokens(self.message_tokens),
344                self.tool_count,
345                crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
346            ),
347        }
348    }
349}
350
351/// A stateful agent: configuration, a model transport, a tool set, and the
352/// running conversation. Drive it with [`Agent::send`].
353/// BP-13 — one hop the run loop's failure-fallback pass performed: the
354/// model it was on, the model it moved to, and the provider failure that
355/// made it move.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct FallbackHop {
358    /// The model that failed.
359    pub from: String,
360    /// The next chain entry, which the request was re-sent against.
361    pub to: String,
362    /// The failure, rendered — the record's `reason`.
363    pub reason: String,
364}
365
366/// BP-13 — whether `error` is the kind of failure ANOTHER MODEL could
367/// plausibly answer, i.e. one the fallback chain exists for.
368///
369/// Deliberately narrow: rate limiting (429) and server-side failures (5xx,
370/// which is where "overloaded" lives) are properties of the model/endpoint
371/// that was asked, so asking a different one is a real remedy. Everything
372/// else — a bad request, a refused key, a decode failure, a tool error —
373/// is the CALLER's problem and would fail identically against every entry
374/// in the chain, so walking it would only multiply the same error by three.
375/// The transport's own retry (`OpenAiProvider::send_with_retry`) has
376/// already run and given up by the time this is consulted.
377pub fn is_failover_worthy(error: &Error) -> bool {
378    matches!(error, Error::Provider { status, .. } if *status == 429 || *status >= 500)
379}
380
381pub struct Agent {
382    config: Config,
383    provider: std::sync::Arc<dyn Provider>,
384    registry: ToolRegistry,
385    history: Vec<ChatMessage>,
386    ctx: ToolContext,
387    /// Cumulative output (completion) tokens across every `send` on this agent.
388    total_output_tokens: u64,
389    /// Names of non-core tools discovered via `tool_search` (B6): advertised
390    /// starting with the *next* request once populated.
391    activated_tools: HashSet<String>,
392    /// The live sidecar writer (A3), if this agent is recording. `None` is
393    /// today's behavior, at zero cost: every append point becomes a no-op.
394    recorder: Option<SidecarWriter>,
395    /// BP-8 (catalog:150 "Append-only durable transcript"): the live
396    /// append-only journal, if one is installed
397    /// ([`Self::set_journal`], armed by the caller when
398    /// [`Config::session_append_only`] is on). Behind an `Arc<Mutex<_>>`
399    /// rather than owned outright because the queue doors
400    /// ([`Self::queue_steer`]) take `&self` — a pending input has to be
401    /// recorded from a shared handle while a turn holds `&mut Agent`.
402    /// `None` (the default) is a no-op at every append point: today's
403    /// behavior, no file created.
404    journal: Option<std::sync::Arc<std::sync::Mutex<crate::session_journal::SessionJournal>>>,
405    /// BP-8 (catalog:151 "In-place conversation tree"): the live
406    /// `SessionTree` for this session, materialized when
407    /// [`Config::session_tree_enabled`] is on. Every recorded message
408    /// becomes a node, and [`Self::rewind_conversation`] moves the active
409    /// branch's leaf — the tree is what makes a rewind lossless (the old
410    /// leaf is preserved under a sibling branch) rather than a truncation.
411    /// `None` (the default, and every preset that leaves the module off) is
412    /// zero cost: nothing is built and nothing is persisted.
413    session_tree: Option<crate::session_tree::SessionTree>,
414    /// BP-8 (catalog:152 "Rewind/rollback conversation"): tails removed by
415    /// rewinds that have not been undone, newest last. Restored from the
416    /// journal on resume, so "undo the rewind" survives a restart.
417    rewind_undo: Vec<Vec<ChatMessage>>,
418    /// BP-8 (catalog:156): the plan as last written to the journal —
419    /// compared against `ctx.plan` so an unchanged plan is not re-journaled
420    /// on every loop iteration.
421    journaled_plan: Vec<crate::session_journal::PlanEntry>,
422    /// Reversible reduction policy (A5/A7/A10). `None` is today's behavior,
423    /// at zero cost: every provider request is built from `self.history`
424    /// verbatim, exactly as before this landed.
425    reduction_policy: Option<ReductionPolicy>,
426    /// The accumulating reduction log (A5): fed back into
427    /// [`reduce::project_messages`] on every request-build so already-applied
428    /// reductions reproduce verbatim across turns and `send` calls (prefix
429    /// stability). `history` itself is never touched by this — see
430    /// `Self::run_loop`.
431    reduction_log: ReductionLog,
432    /// B7: length of the stable, byte-identical-across-turns prefix at the
433    /// front of [`Self::history`] — this agent's own system message plus
434    /// every message of a previously-imported session — set by
435    /// [`Self::load_session`]. `None` (the default) means no session has been
436    /// loaded, so [`crate::provider::apply_cache_plan`] has nothing to
437    /// annotate even under [`CachePlan::ImportedPrefix`].
438    imported_prefix_len: Option<usize>,
439    /// BP-11: set for the duration of a `/compact` so the `pre_compact`
440    /// observer can tell a manual compaction from an automatic trigger.
441    compacting_manually: bool,
442    /// BP-4 (catalog:90 "Environment context block", cx§2 "re-emitted on
443    /// change"): the `# Environment` block currently spliced into
444    /// `history[0]`, verbatim — `None` when `core.env_context` is off (or
445    /// on a construction path that assembles no prompt). Kept so
446    /// [`Self::refresh_env_context`] can locate and replace exactly this
447    /// text when cwd, approval/sandbox policy or the git branch moves
448    /// mid-session, instead of leaving the model reading a block that
449    /// stopped being true.
450    env_context_live: Option<String>,
451    /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): the base
452    /// system prompt currently spliced at the head of `history[0]`,
453    /// verbatim. Kept for the same reason [`Self::env_context_live`] is:
454    /// when the model changes ([`Self::set_model`]) the family's prompt
455    /// changes with it, and the stale text has to be located and REPLACED
456    /// rather than left in front of the new model.
457    base_prompt_live: String,
458    /// BP-5 (catalog D2 "Shell-output injection in templates/skills"): the
459    /// permission-engine authorization every `` !`cmd` `` in a skill or
460    /// command body runs under. Inert (executes nothing) unless
461    /// `[core.skills] shell_injection` is on.
462    shell_injection: crate::skills::ShellInjection,
463    /// BP-4 (catalog:91): blocks spliced into the context AFTER
464    /// construction — see [`Self::inject_context_block`] and
465    /// [`crate::context_injection`]. Empty by default, at zero cost.
466    spliced_context_blocks: Vec<crate::config::ContextInjectionBlock>,
467    /// TR-7 (T20): the injectable side-call ([`reduce::summarize::SpanSummarizer`])
468    /// used to summarize an A10 `TurnsCleared` span, if one is installed
469    /// ([`Self::set_span_summarizer`]). `None` is today's behavior, at zero
470    /// cost: `Self::build_request_messages` never calls
471    /// [`reduce::prepare_cleared_turns_summary`] without one, so
472    /// `policy.summarize_cleared_turns` being on with no summarizer
473    /// installed behaves exactly like it being off (deterministic stub only)
474    /// — never a panic, never a blocked request.
475    span_summarizer: Option<std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>>,
476    /// TR-8 (T5): the tool-schema tier signature (global knob + per-tool
477    /// overrides) as of the last request this agent built, or `None` before
478    /// the first request. Compared against the CURRENT signature at the top
479    /// of every `Self::build_request_messages` call so a tier change made
480    /// mid-session (via [`Self::set_schema_tier`] /
481    /// [`Self::set_tool_schema_tier`]) is detected and flagged to the B7
482    /// cache planner as a cache-bust event (`provider::tier_change_is_cache_bust`).
483    last_tool_schema_tier_signature: Option<u64>,
484    /// PARITY-18 D4 — the target model's context-window size, if the caller
485    /// has armed the guard via [`Self::set_context_limit`]. `None` (the
486    /// default) means no guard: every request is sent unconditionally.
487    /// CLI entry points arm it for their resolved model; direct SDK callers
488    /// retain explicit control through [`Self::set_context_limit`].
489    /// Once set, `Self::run_loop` re-checks
490    /// [`crate::tokens::context_guard`] before EVERY request it builds —
491    /// not just the first — so "never sends an over-context request" holds
492    /// for the whole session, not only a one-shot preflight.
493    context_limit: Option<u64>,
494    /// PARITY-18 D3 — becomes `true` the first time `Self::run_loop`
495    /// actually reaches its real send site (immediately before
496    /// [`Provider::complete`]). Exposed via [`Self::request_issued`] so a
497    /// caller can report "request sent" truthfully — never asserted ahead
498    /// of time, so a pre-delivery failure (guard refusal, a build error) or
499    /// an interactive session that quits before any turn completes is
500    /// reported honestly as "not sent".
501    requests_issued: bool,
502    /// UX-26 (B7-warn): unix-ms wall-clock time this agent last knew the
503    /// active [`CachePlan::ImportedPrefix`] breakpoint to be warm. Seeded by
504    /// [`Self::load_session`] from the just-loaded session's OWN last
505    /// message timestamp (`metadata["timestamp"]`, parsed via
506    /// [`crate::sidecar::rfc3339_to_ms`]) — a cross-process signal: how long
507    /// the resumed conversation has sat idle since ANY tool last touched it,
508    /// which is exactly when Anthropic's server-side cache entry (if one
509    /// ever existed) was last capable of being warm. Refreshed to "now"
510    /// every time `Self::run_loop` actually sends a cache-annotated
511    /// request (an in-process signal: idle time between this agent's own
512    /// turns). `None` when no imported prefix exists yet, or the loaded
513    /// session's last message carries no parseable timestamp — never
514    /// guessed, so the TTL check in [`provider::cache_cold_reason`] simply
515    /// doesn't fire rather than risk a false positive.
516    last_cache_activity_ms: Option<i64>,
517    /// UX-26: whether a PRIOR request already carried a cache_control
518    /// annotation for the current [`Self::imported_prefix_len`] — i.e.
519    /// whether reuse is genuinely "expected" on the NEXT annotated request.
520    /// `false` until the first annotated request goes out (that one is
521    /// establishing the cache entry, a legitimate write, never a "miss") and
522    /// reset to `false` by [`Self::load_session`] whenever the imported
523    /// prefix itself changes.
524    cache_established: bool,
525    /// UX-26 scratch: this turn's cache-warmth context, computed once at the
526    /// top of `Self::build_request_messages` (before the request is sent,
527    /// while `effective_cache_plan`/`busted` are in scope) and consumed once
528    /// in `Self::run_loop` right after `usage` comes back — never read
529    /// across turns, so a stale value can't leak. `(will_annotate,
530    /// cache_established, idle_secs)` — see [`provider::cache_cold_reason`]
531    /// for what each of the first two independently gates.
532    pending_cache_turn: (bool, bool, Option<i64>),
533    /// P4b: the injectable auto-title side-call ([`Self::set_session_titler`]),
534    /// mirroring `Self::span_summarizer`'s "installing one alone changes
535    /// nothing" contract — `Config::auto_title` is the actual gate a caller
536    /// consults before invoking [`Self::auto_title`].
537    session_titler: Option<std::sync::Arc<dyn crate::session_title::SessionTitler + Send + Sync>>,
538    /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
539    /// [`crate::usage_log::UsageRecord`] recorded so far this agent's
540    /// lifetime. Always accumulated (cheap, small) regardless of whether a
541    /// caller ever persists it — see [`Self::usage_records`]/
542    /// [`Self::save_usage_log`].
543    usage_log: Vec<crate::usage_log::UsageRecord>,
544    /// P4b: 0-based index of the NEXT model round-trip, for
545    /// [`crate::usage_log::UsageRecord::turn`].
546    turn_index: usize,
547    /// BP-7 (catalog §4a "Turn/step bracketing records"): the per-round-trip
548    /// marker log — context/usage/finish brackets plus the retry, abort,
549    /// effort and goal markers. Persisted beside the session as
550    /// `<name>.events.jsonl` (see [`Self::save_turn_records`]).
551    turn_records: Vec<crate::turn_record::TurnRecord>,
552    /// BP-7: retries the transport reported, drained after every
553    /// `complete()` so each notice attaches to the round-trip that produced
554    /// it. Only the HTTP provider built by [`Self::new`] writes into this;
555    /// an injected provider simply never records anything.
556    retry_log: std::sync::Arc<crate::provider::RetryLog>,
557    /// BP-7 (catalog §4a "Per-turn cost/usage accounting", "Turn/budget
558    /// caps"): the price to bill this agent's model at, resolved at
559    /// construction from [`Config::price_input_per_mtok`]/
560    /// [`Config::price_output_per_mtok`] or [`crate::pricing`]'s table, and
561    /// re-resolved by [`Self::set_model`]. `None` = unpriceable, so no cost
562    /// is recorded (never a guess).
563    model_price: Option<crate::pricing::ModelPrice>,
564    /// BP-7: dollars this agent has spent across its whole lifetime — the
565    /// counter [`Config::max_budget_usd`] is measured against.
566    total_cost_usd: f64,
567    /// BP-7: tool calls this agent has executed across its whole lifetime —
568    /// the counter [`Config::max_steps`] is measured against.
569    total_steps: usize,
570    /// BP-7 (catalog §4a "Background subagents + resume"): a finished
571    /// child's post-system-prompt transcript, kept after the reap so
572    /// `subagent_resume` can restore its context in-process. A session
573    /// with a subagent store attached also has it on disk; this makes
574    /// resume work for an embedder that never attached one.
575    reaped_subagents: std::collections::HashMap<String, Vec<ChatMessage>>,
576    /// BP-7 (catalog §4a "Goals"): the session's standing objective, when
577    /// `capabilities.todos.goals` is on and one has been set. Restated at
578    /// the TAIL of every request while it stands (see
579    /// [`crate::goals::GoalRecord::reminder`]) and persisted as
580    /// `<session>.goal.json` — never written into `history`, so the
581    /// transcript stays exactly what the conversation was.
582    goal: Option<crate::goals::GoalRecord>,
583    /// P4b (§1.7/§3.1 `core.steering`, pi§3 semantics): queued mid-turn
584    /// steering messages — drained at the top of `Self::run_loop`'s next
585    /// iteration (pi's "steer = after current tool calls"). Empty by
586    /// default, at zero cost: `Self::run_loop` skips the drain entirely
587    /// when empty.
588    steer_queue: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
589    /// P4b: queued follow-up messages — drained only once the loop is
590    /// otherwise idle (pi's "follow-up = at idle"), i.e. exactly the point
591    /// `Self::run_loop` would otherwise return a final answer.
592    follow_up_queue: std::collections::VecDeque<String>,
593    /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
594    /// `(tool name, canonical JSON args)` of the most recent tool call, if
595    /// [`Config::doom_loop_threshold`] is armed — `None` before the first
596    /// call this agent has run. See [`Self::check_doom_loop`].
597    doom_loop_last_call: Option<(String, String)>,
598    /// P4c: how many times [`Self::doom_loop_last_call`] has repeated
599    /// consecutively so far (starts at 1 on the call that SET it).
600    doom_loop_streak: u32,
601    /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`): every
602    /// [`crate::model_change::ModelChangeRecord`] [`Self::switch_model`] has
603    /// created so far this agent's lifetime. Always empty when
604    /// `Config::model_switch_allow_switch` is off (the default) or no
605    /// switch has happened yet.
606    model_change_log: Vec<crate::model_change::ModelChangeRecord>,
607    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
608    /// once at construction when [`Config::session_git_metadata`] is on;
609    /// `None` when the gate is off (the default) or the best-effort git
610    /// probe found nothing (not a repo, `git` missing). See
611    /// [`Self::git_metadata`]/[`Self::save_git_metadata`].
612    git_metadata: Option<crate::git_metadata::GitMetadataRecord>,
613    /// P5-1 (§2.10, session-scoped "approve for session" cache): populated
614    /// only when a [`crate::permissions::PermissionsApprovalHandler`]
615    /// returns [`crate::permissions::ApprovalOutcome::AllowForSession`] —
616    /// see [`Self::prepare_tool_call`]'s `Config::permissions_enabled`
617    /// branch. Always constructed (cheap, empty) regardless of whether the
618    /// engine is ever active — the same "zero cost when off" posture as
619    /// [`Self::doom_loop_last_call`].
620    permissions_approval_cache: crate::permissions::ApprovalCache,
621    /// P5-1: the non-interactive decision seam a caller installs via
622    /// [`Self::set_permissions_approval_handler`] — mirrors
623    /// `Self::span_summarizer`/[`Self::session_titler`]'s "installing one
624    /// alone changes nothing, `Config::permissions_enabled` is the actual
625    /// gate" pattern. `None` (the default) means every `Ask`-tier decision
626    /// is denied (fail-closed — see
627    /// `crate::permissions::approval::PermissionsApprovalHandler`'s doc
628    /// comment).
629    permissions_approval_handler:
630        Option<std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>>,
631    /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): MCP server
632    /// prompts registered via [`Self::register_mcp_prompt`], keyed by their
633    /// ALREADY-NAMESPACED command name (`mcp__<server>__<prompt>` — see
634    /// [`crate::mcp::McpPromptSource`]'s doc comment for why that namespace
635    /// is what keeps an untrusted server's prompt from ever colliding with
636    /// a trusted `Config::prompts` entry). Empty by default, at zero cost:
637    /// [`Self::expand_prompt_async`] only consults this after
638    /// `Config::prompts` finds no match.
639    mcp_prompts: std::collections::HashMap<String, Box<dyn crate::sdk::SdkPromptSource>>,
640    /// BP-6 (catalog D2 "Skills (progressive-disclosure packages)", D7
641    /// "Skill discovery from multiple roots"): the SKILL.md packages
642    /// discovered for this config, frontmatter only — name, description,
643    /// version and the manifest path. Never a body: a body is read from
644    /// disk on invocation (`/name`, `/skill:name`, a `$slug` mention, or
645    /// the `skill` tool) and nowhere else. Empty unless `[core.skills]` is
646    /// on AND names a harness whose roots to read.
647    skills: Vec<crate::skills::LoopSkill>,
648    /// P5-3 (§2 module 9): how deep in the spawn tree THIS agent is — `0`
649    /// for a top-level agent. Set from [`Config::subagent_depth`] at
650    /// construction; `Self::run_spawn_subagent` builds a child `Config`
651    /// with `subagent_depth = self.subagent_depth + 1` and ALSO overwrites
652    /// the freshly-built child `Agent`'s own field to match (belt-and-
653    /// suspenders — the child never has to trust its own `Config` alone).
654    subagent_depth: usize,
655    /// P5-3 (resource bound, "must not fork-bomb"): the shared, tree-wide
656    /// concurrency gauge every spawn (this agent's own, and every
657    /// descendant's) increments/decrements against
658    /// (`crate::subagents::try_acquire`/`ConcurrencyGuard`). A TOP-level
659    /// agent gets a fresh `Arc::new(AtomicUsize::new(0))` at construction;
660    /// `Self::run_spawn_subagent` clones this SAME `Arc` into every child it
661    /// spawns (never a fresh one), so a cap of N holds across the WHOLE
662    /// tree regardless of its branching shape — a parent with 3 children
663    /// each spawning 3 more shares one counter, not nine independent ones.
664    subagent_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
665    /// P5-3 (D3 "background+resume"): background subagents this agent has
666    /// spawned and not yet reaped via `subagent_status`, keyed by their
667    /// `child_agent_id`. Each entry's `JoinHandle` moves its own
668    /// [`crate::subagents::ConcurrencyGuard`] into the spawned task, so the
669    /// concurrency slot is held for exactly as long as the child is
670    /// actually running, independent of whether/when the parent polls.
671    background_subagents: std::collections::HashMap<String, BackgroundSubagent>,
672    /// P5-3 (§2.2 C6 "parent-surfaced queue"): approval requests a
673    /// `background_prompts = "parent"` child raised, queued here rather
674    /// than blocking (see [`crate::subagents::QueuedApproval`]'s doc
675    /// comment — each is already resolved `Deny` by the time it lands
676    /// here). Exposed read-only via [`Self::pending_child_approvals`].
677    /// Always constructed (cheap, empty) regardless of whether background
678    /// spawning is ever used, same "zero cost when off" posture as
679    /// [`Self::permissions_approval_cache`].
680    pending_child_approvals:
681        std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
682    /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain — see
683    /// [`crate::subagents::ParentQueueApprovalHandler`]'s doc comment for
684    /// the "never blocks" contract this OVERRIDES only when a factory is
685    /// installed): when `Some`, `Self::run_spawn_subagent` uses THIS
686    /// factory — instead of constructing the default never-blocking
687    /// [`crate::subagents::ParentQueueApprovalHandler`] — to build the
688    /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
689    /// child gets. Installed via
690    /// [`Self::set_child_approval_handler_factory`] by a `tui` embedder
691    /// that wants queued child approvals to be genuinely ANSWERABLE
692    /// (blocks the child's tool call until the parent resolves it, or
693    /// denies if the factory's handler's channel is ever dropped/closed —
694    /// still fail-closed, never a hang past process lifetime). `None` (the
695    /// default) preserves P5-3's shipped behavior byte-for-byte: every
696    /// `background_prompts = "parent"` child still gets the immediate-deny
697    /// `ParentQueueApprovalHandler`, and [`Self::pending_child_approvals`]
698    /// stays exactly the read-only audit view it already is.
699    child_approval_handler_factory: Option<std::sync::Arc<ChildApprovalHandlerFactory>>,
700    /// P5-3 (D5 "subagent transcripts… persisted + linked"): an optional
701    /// `(store, this agent's own session name)` pair installed via
702    /// [`Self::set_subagent_store`] — mirrors [`Self::set_recorder`]/
703    /// [`Self::set_span_summarizer`]'s "installing one alone changes
704    /// nothing" pattern. `None` (the default) means a spawned child's
705    /// transcript/lineage is still joined back into THIS agent's context
706    /// (the foreground/background mechanics work either way) but nothing
707    /// is written to a [`crate::store::SessionStore`] — no behavior change
708    /// for any caller that never installs one (e.g. every pre-P5-3 caller).
709    subagent_store: Option<(std::sync::Arc<crate::store::SessionStore>, String)>,
710    /// Imported Claude runtime state. The manifest can be paused or active,
711    /// but this Agent contains no scheduler or timer handle; an embedding
712    /// driver owns execution and persistence.
713    claude_runtime_manifest: Option<crate::claude_runtime_state::ClaudeRuntimeManifest>,
714    /// P5-6 (§2 module 4 `tools.background`, D10 "bg-manager"): background
715    /// OS processes spawned via `background_exec`, keyed by job id, tracked
716    /// until reaped (a terminal `background_status` poll, or an explicit
717    /// `background_kill`) — see [`BackgroundJob`]'s doc comment. Always
718    /// constructed (cheap, empty), same "zero cost when off" posture as
719    /// [`Self::background_subagents`].
720    background_jobs: std::collections::HashMap<String, BackgroundJob>,
721    /// P5-6 (resource bound, mirroring [`Self::subagent_concurrency_gauge`]'s
722    /// own precedent): the shared concurrency gauge every `background_exec`
723    /// call on this agent increments/decrements against
724    /// (`crate::subagents::try_acquire`/`ConcurrencyGuard` — reused
725    /// verbatim, a second independent gauge instance scoped to background
726    /// JOBS rather than subagent SPAWNS).
727    background_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
728    /// P5-9 (§2 module 20 `checkpoint`): the write-path-interception
729    /// observer installed on [`Self::ctx`]'s `write_observer` (as a
730    /// `dyn WriteObserver`), held here ADDITIONALLY as its concrete type so
731    /// `Self::run_loop` can call
732    /// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
733    /// `None` when `Config::checkpoint_enabled` is `false` (the default) or
734    /// the shadow store failed to open — see
735    /// [`crate::checkpoint::observer_for_config`].
736    checkpoint_observer: Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
737    /// P5-11 (§2 module 28 `lsp`): the LSP server registry installed (via
738    /// `crate::lsp::LspDiagnosticsObserver`) on `Self::ctx`'s
739    /// `write_observer` chain, held here ADDITIONALLY as its concrete type
740    /// so `impl Drop for Agent` can reach
741    /// [`crate::lsp::LspManager::kill_all_sync`] (no orphaned language-
742    /// server processes) and a clean-exit caller can reach
743    /// [`crate::lsp::LspManager::shutdown_all`] for a graceful handshake.
744    /// `None` when `Config::lsp_enabled` is `false` (the default).
745    lsp_manager: Option<std::sync::Arc<crate::lsp::LspManager>>,
746}
747
748/// P5-6 (§2 module 4 `tools.background`): one background-spawned OS process
749/// this agent is tracking, awaiting a `background_status`/`background_list`
750/// poll (or `background_kill`/agent drop) to reap or terminate it.
751///
752/// **Real process, not a child agent.** Unlike [`BackgroundSubagent`] (which
753/// wraps a whole recursive child [`Agent`] loop against the SAME mock/real
754/// provider), this wraps a plain OS subprocess spawned via
755/// `crate::tools::build_sandboxed_sh` — the exact function
756/// [`crate::tools::BashTool::execute`] itself calls, so a background
757/// command gets byte-identical sandboxing/cwd/env handling to a foreground
758/// `bash` call (build brief: "reuse the bash tool's execution + sandbox
759/// path").
760struct BackgroundJob {
761    /// The live process handle — kept directly on the job (not moved into a
762    /// spawned task) so [`Agent::run_background_status`]/
763    /// [`Agent::run_background_list`] can call the SYNCHRONOUS,
764    /// non-blocking `Child::try_wait` to observe exit status, and
765    /// [`Agent::run_background_kill`]/[`impl Drop for Agent`] can call the
766    /// SYNCHRONOUS `Child::start_kill` for a REAL process kill — never just
767    /// a `tokio::task::JoinHandle::abort` (which would only cancel a Rust
768    /// future, not the OS process it spawned). `kill_on_drop(true)` was set
769    /// at spawn time as defense-in-depth: even a `BackgroundJob` dropped
770    /// through some path OTHER than the explicit kill call sites below
771    /// still kills its child (a documented tokio behavior; a no-op if the
772    /// process already exited).
773    child: tokio::process::Child,
774    /// The exact command text this job is running — the SAME text that was
775    /// already checked against the permissions engine at spawn time (see
776    /// [`Agent::background_permission_denial`]).
777    command: String,
778    /// The OS process id, captured once at spawn time (before `child` is
779    /// ever mutated) — surfaced in every status/list/kill result, and the
780    /// only thing an OUTSIDE observer (e.g. a test proving real
781    /// termination) needs to check liveness independent of this process's
782    /// own bookkeeping.
783    pid: Option<u32>,
784    /// Bounded, incrementally-appended combined stdout+stderr capture —
785    /// written to by the reader tasks [`Agent::run_background_exec`] spawns
786    /// right after `child.stdout`/`child.stderr` are taken, read by every
787    /// status/list poll. Shared via `Arc` since the reader tasks outlive
788    /// this method call.
789    output: std::sync::Arc<crate::background::CapturedOutput>,
790    /// Unix-ms wall-clock time the spawn happened.
791    started_at_ms: i64,
792    /// Set by [`Agent::run_background_kill`] — [`Agent::run_background_status`]/
793    /// [`Agent::run_background_list`] report [`crate::background::JobStatus::Killed`]
794    /// unconditionally once this is `true`, rather than racing
795    /// `Child::try_wait` to see whether the kill signal has landed yet.
796    killed: bool,
797    /// The concurrency-gauge slot this job holds for as long as it remains
798    /// in [`Agent::background_jobs`] — dropped (freeing the slot) when this
799    /// `BackgroundJob` is removed from the map (a terminal reap, or an
800    /// explicit kill), exactly mirroring [`BackgroundSubagent`]'s own
801    /// "guard held for as long as it's tracked, not just while the process
802    /// is alive" posture (§2 module 9 precedent, kept consistent here).
803    _guard: crate::subagents::ConcurrencyGuard,
804}
805
806/// P5-6: the non-blocking status read [`Agent::run_background_status`]/
807/// [`Agent::run_background_list`] share — `job.killed` (set by
808/// [`Agent::run_background_kill`]) always wins over a fresh `try_wait`,
809/// since a kill signal racing the OS reaping the process is otherwise
810/// indistinguishable from "still running" for one poll cycle; reporting
811/// `Killed` unconditionally once requested avoids that race entirely. A
812/// `try_wait` error (would only happen if this job's id were somehow
813/// double-reaped, which the map ownership below already prevents) is
814/// treated as "no news yet" — `Running` — rather than inventing a made-up
815/// exit code.
816fn background_job_status(job: &mut BackgroundJob) -> crate::background::JobStatus {
817    if job.killed {
818        return crate::background::JobStatus::Killed;
819    }
820    match job.child.try_wait() {
821        Ok(Some(status)) => crate::background::JobStatus::Exited(status.code()),
822        Ok(None) | Err(_) => crate::background::JobStatus::Running,
823    }
824}
825
826/// Fable-5 review (HIGH, "grandchildren orphaned on kill AND agent-drop"):
827/// the shared real-kill body for both [`Agent::run_background_kill`] and
828/// `impl Drop for Agent` — sends `SIGKILL` to `job`'s ENTIRE process group,
829/// not just the one directly-tracked pid, so a surviving `&` job, pipeline
830/// stage, or double-forking daemon spawned by the job is killed too, then
831/// reaps the group leader so it doesn't linger as a zombie.
832///
833/// Relies on the spawn site (`Agent::run_background_exec`) having put the
834/// job in its OWN new process group via `Command::process_group(0)` — which
835/// makes the leader's pgid equal to its own pid, so `job.pid` doubles as the
836/// group id here.
837#[cfg(unix)]
838fn kill_job_process_group(job: &mut BackgroundJob) {
839    if let Some(pid) = job.pid {
840        // SAFETY: `libc::kill` with a negative pid is `killpg` — it only
841        // ever sends a signal (never dereferences memory), so this is safe
842        // regardless of whether the group is still alive. A `-1`/`ESRCH`
843        // return means the leader (and thus the whole group, since a group
844        // can't outlive its leader) already exited — not an error, just
845        // "already dead", exactly like `Child::start_kill`'s own documented
846        // no-op-on-already-exited contract.
847        unsafe {
848            libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
849        }
850    }
851    // Belt-and-suspenders for the leader itself — `kill_on_drop(true)` set
852    // at spawn time is the same outcome via a different (implicit) path —
853    // then reap it so the SIGKILL we just delivered doesn't leave a zombie
854    // behind.
855    let _ = job.child.start_kill();
856    let _ = job.child.try_wait();
857}
858
859/// Non-unix fallback: no portable process-group primitive is wired up here
860/// (same posture as `crate::tools::build_sandboxed_sh`'s own platform
861/// split) — falls back to the pre-fix per-child kill. A background job that
862/// spawns a surviving grandchild process on a non-Unix target is a
863/// documented residual, not silently claimed fixed by this cfg arm.
864#[cfg(not(unix))]
865fn kill_job_process_group(job: &mut BackgroundJob) {
866    let _ = job.child.start_kill();
867}
868
869/// P5-6 (D1 "monitor/event feed", "output captured incrementally +
870/// BOUNDED"): spawn a fire-and-forget reader task that continuously drains
871/// `reader` (a piped `ChildStdout`/`ChildStderr`) into `output`, bounded at
872/// `cap` bytes. Reading NEVER stops at the cap — only what's RETAINED is
873/// bounded ([`crate::background::CapturedOutput::append`]'s own contract)
874/// — because a background job's child process would otherwise block
875/// forever writing to a full, undrained OS pipe once this stopped reading
876/// it, silently hanging real work behind an apparently-"running" job. The
877/// task exits on its own once the pipe reaches EOF (the process closed the
878/// descriptor, whether by exiting or being killed) — no explicit
879/// abort/cleanup call site is needed; a detached `tokio::spawn` this short-
880/// lived is not the kind of orphaned-task risk `impl Drop for Agent`'s own
881/// doc comment is about (that one concerns a whole recursive provider-
882/// calling child AGENT loop, not a bounded byte-copy loop that ends the
883/// instant its source pipe closes).
884fn spawn_output_reader<R>(
885    reader: R,
886    output: std::sync::Arc<crate::background::CapturedOutput>,
887    cap: usize,
888) -> tokio::task::JoinHandle<()>
889where
890    R: tokio::io::AsyncRead + Unpin + Send + 'static,
891{
892    tokio::spawn(async move {
893        use tokio::io::AsyncReadExt;
894        let mut reader = reader;
895        let mut buf = [0u8; 8192];
896        loop {
897            match reader.read(&mut buf).await {
898                Ok(0) => break,
899                Ok(n) => {
900                    let chunk = String::from_utf8_lossy(&buf[..n]);
901                    output.append(&chunk, cap);
902                }
903                Err(_) => break,
904            }
905        }
906    })
907}
908
909/// BP-7 (catalog §4a "Named agent definitions as data"): merge
910/// `<cwd>/.claude/agents/*.md` into `config.subagents_definitions`.
911///
912/// Runs for every `Agent` whose `subagents` module is on, whatever preset
913/// it came from — before BP-7 the `.md` loader was reachable only from the
914/// Claude emulate/resume path, so a cc-parity or cx-parity session ignored
915/// definitions sitting right there in the repo.
916///
917/// * A no-op when the module is off (the default), and for every spawned
918///   CHILD (`subagent_depth > 0`), which already inherits its parent's
919///   resolved definitions verbatim.
920/// * A config-table entry WINS over a discovered file of the same name:
921///   `[capabilities.subagents.agents.<name>]` is explicit configuration,
922///   the file is discovery.
923/// * A malformed file is skipped with a warning, never a failed
924///   construction: `Agent::with_provider` has no error channel, and a
925///   broken agent file in some repo must not make the harness unusable
926///   there. (The emulate/resume path keeps its own strict behavior, where
927///   a definition the resumed session may depend on going missing IS worth
928///   failing over.)
929fn merge_project_agent_definitions(config: &mut Config) {
930    if !config.subagents_enabled || config.subagent_depth > 0 {
931        return;
932    }
933    match crate::claude_compat::load_project_agents(&config.cwd) {
934        Ok(agents) => {
935            for agent in agents {
936                config
937                    .subagents_definitions
938                    .entry(agent.definition.name.clone())
939                    .or_insert(agent.definition);
940            }
941        }
942        Err(e) => {
943            tracing::debug!(
944                error = %e,
945                "skipping .claude/agents discovery: a definition file could not be parsed"
946            );
947        }
948    }
949}
950
951/// P5-3: one background-spawned child this agent is tracking, awaiting a
952/// `subagent_status` poll (or agent drop) to reap it.
953struct BackgroundSubagent {
954    /// Resolves to `(child_agent_id, child's final result, the child's own
955    /// post-system-prompt history — for D5 transcript persistence once
956    /// reaped)` — the concurrency-guard slot for this child is held INSIDE
957    /// the spawned future (moved in at spawn time), so it releases the
958    /// instant the child's own run loop finishes, not when the parent gets
959    /// around to polling.
960    handle: tokio::task::JoinHandle<(String, Result<String>, Vec<ChatMessage>)>,
961    /// The task/prompt text the child was spawned with (surfaced by a
962    /// `"pending"` status poll, since the handle alone can't answer "what
963    /// is it doing").
964    task: String,
965    /// The named `agent_type` spawned, if any.
966    agent_type: Option<String>,
967    /// Unix-ms wall-clock time the spawn happened.
968    started_at_ms: i64,
969    /// BP-7 (catalog §4a "Background subagents + resume"): the child's own
970    /// steering inbox, captured before the child moved into its task.
971    ///
972    /// This IS the mailbox. `SteerInbox` was built (P4b) to be writable
973    /// while an active turn holds `&mut Agent` — exactly the property a
974    /// message-to-a-running-child needs — so the mailbox is that existing
975    /// seam reached from outside, not a second delivery channel with its
976    /// own ordering rules. A message lands at the top of the child's next
977    /// loop iteration, per `Config::steering_mode`.
978    mailbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
979}
980
981/// P5-3 safety hardening (Fable-5 review, MEDIUM-LOW "orphaned billed
982/// spend"): a dropped parent must not leave a detached background child
983/// running against a REAL provider. Without this, a parent dropped
984/// mid-run (the caller's own process exits the scope, panics, or simply
985/// stops polling) leaves every still-running `BackgroundSubagent::handle`
986/// as an orphaned `tokio::spawn` task: nothing had ever awaited or
987/// aborted it, so it runs to its own (`max_iterations`-bounded)
988/// completion regardless — bounded but real provider spend nobody is
989/// paying attention to.
990///
991/// `.abort()` on a [`tokio::task::JoinHandle`] is safe to call
992/// unconditionally, including on an ALREADY-finished task (a documented
993/// no-op there — see tokio's `JoinHandle::abort` docs) — so this never
994/// needs to distinguish "still running" from "already done"; a background
995/// child that already finished and is merely awaiting a `subagent_status`
996/// reap is untouched in practice (aborting a finished task changes
997/// nothing observable). For a task still mid-flight, tokio cancels it at
998/// its next `.await` point, which drops that future in place — including
999/// the `_guard: ConcurrencyGuard` moved into it at spawn time (see
1000/// `Self::run_spawn_subagent`'s `tokio::spawn` body) — so the
1001/// concurrency-gauge slot is released exactly the same way a normal
1002/// completion releases it (`ConcurrencyGuard`'s own `Drop`, in
1003/// `crate::subagents`). No separate cleanup call site to forget.
1004///
1005/// Deliberately does NOT touch [`Self::pending_child_approvals]` or
1006/// `Self::subagent_store` — this is purely "stop burning provider
1007/// calls on behalf of a caller who's gone", not a transcript-persistence
1008/// path (a child aborted mid-flight has no finished result to persist;
1009/// see this build's named residual on abort-time transcript loss).
1010impl Drop for Agent {
1011    fn drop(&mut self) {
1012        for (child_id, bg) in self.background_subagents.drain() {
1013            // Named, not silent: a child that was still running gets its
1014            // provider calls cut off here — worth a trace even though
1015            // there's no transcript left to persist (the future is
1016            // dropped mid-flight, before it ever returns a result).
1017            if !bg.handle.is_finished() {
1018                tracing::debug!(
1019                    child_id = %child_id,
1020                    "parent Agent dropped: aborting still-running background subagent \
1021                     to stop further provider spend"
1022                );
1023            }
1024            bg.handle.abort();
1025        }
1026        // P5-6 (§2 module 4 `tools.background`, build brief "on agent drop
1027        // / session end, jobs MUST be killed... real process kill via the
1028        // child handle's kill(), not just tokio task abort"): a REAL OS
1029        // process, not a Rust task — `Child::start_kill` (synchronous, no
1030        // `.await` needed, so callable from this non-async `Drop::drop`)
1031        // sends the actual kill signal; a no-op, per its own docs, on a
1032        // job that already exited. `kill_on_drop(true)` (set at spawn
1033        // time) is a second, independent line of defense for the same
1034        // outcome, but this explicit loop is what makes the guarantee
1035        // provable/traceable rather than relying solely on an implicit
1036        // tokio runtime behavior.
1037        for (job_id, mut job) in self.background_jobs.drain() {
1038            if !job.killed {
1039                tracing::debug!(
1040                    job_id = %job_id,
1041                    command = %job.command,
1042                    "parent Agent dropped: killing still-tracked background job's real \
1043                     OS process (and its whole process group — see \
1044                     `kill_job_process_group`)"
1045                );
1046            }
1047            kill_job_process_group(&mut job);
1048        }
1049        // P5-11 (§2 module 28 `lsp`, build brief "no orphaned language-
1050        // server processes"): a REAL OS process, same rationale as the
1051        // background-job loop just above — `kill_all_sync` is
1052        // synchronous (`Child::start_kill`, no `.await` needed, so
1053        // callable from this non-async `Drop::drop`), SIGKILLs each
1054        // server's WHOLE process group (unix — same `kill_job_process_group`
1055        // mechanism as the background-job loop above, so worker
1056        // grandchildren like rust-analyzer's proc-macro server or
1057        // typescript-language-server's `tsserver` are killed too, not just
1058        // the one directly-tracked pid), and is provable/traceable rather
1059        // than relying solely on `kill_on_drop(true)`'s implicit tokio
1060        // runtime behavior (which remains a second, independent line of
1061        // defense on every spawned `LspClient`).
1062        if let Some(lsp) = &self.lsp_manager {
1063            lsp.kill_all_sync();
1064        }
1065    }
1066}
1067
1068/// P4 (§1.8 credential-helper indirection, D6 row): run an `api_key_cmd`
1069/// through the shell and return its trimmed stdout. Runs via `sh -c` (POSIX
1070/// shell, matching pi's `!command` precedent) so the configured string can
1071/// use pipes/substitution, e.g. `pass show api-key`. Never panics or
1072/// propagates an error: a spawn failure or non-zero exit is reported via
1073/// `tracing::warn!` and returns an empty `String`, which
1074/// `Agent::new`'s resolution chain treats exactly like an unset helper —
1075/// falling through to `Config::api_key_env`.
1076fn run_api_key_cmd(cmd: &str) -> String {
1077    match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
1078        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
1079        Ok(out) => {
1080            tracing::warn!(
1081                "api_key_cmd exited with status {:?}; falling back to api_key_env",
1082                out.status.code()
1083            );
1084            String::new()
1085        }
1086        Err(e) => {
1087            tracing::warn!("api_key_cmd failed to run ({e}); falling back to api_key_env");
1088            String::new()
1089        }
1090    }
1091}
1092
1093/// BP-9 (§3.1 `core.api_key_command`, D6 row "Credential helpers /
1094/// keyring"): run an ARGV credential helper and return its trimmed stdout.
1095/// No shell is involved — `argv[0]` is exec'd with the rest as arguments —
1096/// so a helper path with spaces, or an argument containing `$`/`;`, means
1097/// what it says. Same never-panics, fall-through-on-failure contract as
1098/// [`run_api_key_cmd`]: an empty result is treated as "no helper".
1099pub(crate) fn run_api_key_command(argv: &[String]) -> String {
1100    let Some((program, args)) = argv.split_first() else {
1101        return String::new();
1102    };
1103    match std::process::Command::new(program).args(args).output() {
1104        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
1105        Ok(out) => {
1106            tracing::warn!(
1107                "api_key_command exited with status {:?}; trying the next credential source",
1108                out.status.code()
1109            );
1110            String::new()
1111        }
1112        Err(e) => {
1113            tracing::warn!(
1114                "api_key_command failed to run ({e}); trying the next credential source"
1115            );
1116            String::new()
1117        }
1118    }
1119}
1120
1121/// P4c (§1.2/§3.1 `core.shell_env_snapshot`, SPLIT CC+CX row, catalog:338):
1122/// capture the user's interactive login-shell environment ONCE, best-effort.
1123/// Runs `$SHELL -lc env` (falling back to `sh -lc env` when `$SHELL` is
1124/// unset) — a LOGIN shell (`-l`) sources the user's rc files, which is
1125/// exactly the sourcing `bash` calls should no longer need to repeat once
1126/// this snapshot is in hand. Never panics: any failure (spawn error,
1127/// non-zero exit, unparseable output) returns an empty map, which
1128/// `ToolContext::shell_env`'s "no-op when `None`/empty" contract already
1129/// treats as harmless.
1130fn capture_shell_env() -> std::collections::HashMap<String, String> {
1131    let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
1132    let out = match std::process::Command::new(&shell)
1133        .arg("-lc")
1134        .arg("env")
1135        .output()
1136    {
1137        Ok(o) if o.status.success() => o.stdout,
1138        Ok(o) => {
1139            tracing::warn!(
1140                "shell_env_snapshot: `{shell} -lc env` exited with status {:?}; snapshot is empty",
1141                o.status.code()
1142            );
1143            return std::collections::HashMap::new();
1144        }
1145        Err(e) => {
1146            tracing::warn!(
1147                "shell_env_snapshot: failed to run `{shell} -lc env` ({e}); snapshot is empty"
1148            );
1149            return std::collections::HashMap::new();
1150        }
1151    };
1152    let text = String::from_utf8_lossy(&out);
1153    let mut map = std::collections::HashMap::new();
1154    for line in text.lines() {
1155        if let Some((k, v)) = line.split_once('=') {
1156            if !k.is_empty() {
1157                map.insert(k.to_string(), v.to_string());
1158            }
1159        }
1160    }
1161    map
1162}
1163
1164/// Build the [`ToolContext`] an [`Agent`] hands to every tool call, folding
1165/// in every P4c per-tool config knob (§1.2) alongside the pre-existing
1166/// `cwd`/`sandbox` — shared by [`Agent::with_parts`]/[`Agent::with_provider_arc`]
1167/// so the two construction paths can never drift apart on which config
1168/// fields reach the context. Also builds (P5-9) the
1169/// [`crate::checkpoint::CheckpointObserver`], if `config.checkpoint_enabled`
1170/// — installed on the returned context's `write_observer` AND returned
1171/// separately (as the concrete type) so `Agent::run_loop` can call
1172/// [`crate::checkpoint::CheckpointObserver::begin_turn`] once per turn.
1173/// `None`/no-op end to end when the module is off — see
1174/// [`crate::checkpoint::observer_for_config`]'s own doc comment for the
1175/// default-off byte-identity guarantee.
1176///
1177/// P5-11 (§2 modules 28/29, D-5 "shared write-path interception seam"):
1178/// `crate::formatters::observer_for_config`/`crate::lsp::manager_for_config`
1179/// are folded into the SAME `write_observer` slot via
1180/// [`crate::tools::WriteObserverChain`], in the design's required order —
1181/// `checkpoint -> formatters -> lsp` (checkpoint's pre-image capture must
1182/// see the file before ANY mutation; lsp's diagnostics must see the file
1183/// AFTER formatting, never before). When 0 or 1 of the three modules is
1184/// active, this degrades to exactly what P5-9 shipped (`None`, or the
1185/// single concrete observer installed directly) — no chain wrapper is
1186/// introduced unless there is actually more than one observer to order,
1187/// keeping every single-module (or all-off) configuration byte-identical
1188/// to before this function grew multi-observer support. The `lsp` manager
1189/// is ALSO returned separately (like `checkpoint_observer`), so
1190/// `Agent`'s `Drop` impl can reach `crate::lsp::LspManager::kill_all_sync`
1191/// regardless of how the chain is shaped.
1192/// BP-2: `pub(crate)` so a parity test can build the SAME `ToolContext` an
1193/// `Agent` would from a resolved preset's `Config` and drive a registry
1194/// tool through it — a tool's behavior under a preset is exactly the
1195/// composition of the two, and a test that hand-assembled a context would
1196/// be proving an unwired function.
1197pub(crate) fn build_tool_context(
1198    config: &Config,
1199) -> (
1200    ToolContext,
1201    Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
1202    Option<std::sync::Arc<crate::lsp::LspManager>>,
1203) {
1204    let shell_env = if config.shell_env_snapshot {
1205        Some(std::sync::Arc::new(capture_shell_env()))
1206    } else {
1207        None
1208    };
1209    let checkpoint_observer = crate::checkpoint::observer_for_config(config);
1210    let format_observer = crate::formatters::observer_for_config(config);
1211    let lsp_manager = crate::lsp::manager_for_config(config);
1212    let lsp_observer = lsp_manager
1213        .clone()
1214        .map(|m| std::sync::Arc::new(crate::lsp::LspDiagnosticsObserver::new(m)));
1215    let mut observers: Vec<std::sync::Arc<dyn crate::tools::WriteObserver>> = Vec::new();
1216    if let Some(cp) = &checkpoint_observer {
1217        observers.push(cp.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1218    }
1219    if let Some(f) = &format_observer {
1220        observers.push(f.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1221    }
1222    if let Some(l) = &lsp_observer {
1223        observers.push(l.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
1224    }
1225    let write_observer: Option<std::sync::Arc<dyn crate::tools::WriteObserver>> =
1226        match observers.len() {
1227            0 => None,
1228            1 => observers.into_iter().next(),
1229            _ => Some(std::sync::Arc::new(crate::tools::WriteObserverChain::new(
1230                observers,
1231            ))),
1232        };
1233    let ctx = ToolContext {
1234        cwd: config.cwd.clone(),
1235        // BP-10 (catalog row "Additional working directories"): the
1236        // `--add-dir`/`core.additional_dirs` roots reach the TOOLS now,
1237        // not just the project-context walk — `ToolContext::check_write`,
1238        // the OS backstop's writable set, and the permissions engine's
1239        // path rules all read them. Empty (the default) is byte-identical
1240        // to confining everything to `cwd`.
1241        extra_roots: config.additional_dirs.clone(),
1242        sandbox: config.sandbox,
1243        multimodal_read: config.read_file_multimodal,
1244        // BP-2 (§1.2 `core.tools.read_file.line_numbers`, catalog:26).
1245        read_line_numbers: config.read_file_line_numbers,
1246        require_read_before_edit: config.edit_file_require_read_before_edit,
1247        // BP-2: path → content hash at read time (`ToolContext::read_state`).
1248        read_paths: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
1249        notebook_aware: config.edit_file_notebook_aware,
1250        shell_env,
1251        nested_instructions: config.nested_instructions,
1252        injected_instruction_dirs: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
1253        // BP-5: filled in by `Agent::with_parts` (the one construction path
1254        // that assembles a prompt, and therefore the one that knows which
1255        // rules were held back); empty everywhere else.
1256        path_rules: std::sync::Arc::new(Vec::new()),
1257        injected_rule_files: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
1258        // P5-1 (§2 module 12 carry-forward): now sourced from real config
1259        // (`capabilities.permissions.sandbox.network.*`, wired by
1260        // `configfile::materialize_config`) instead of always `None`. `None`
1261        // (the default, unchanged when the config never sets it) is still
1262        // byte-identical to today's behavior.
1263        network_policy: config.network_policy.clone(),
1264        // BP-10 (catalog row "Allow/ask/deny rule language", the DOMAIN
1265        // subject): the config's own rule arrays reach the network surface
1266        // too, so a `domain(...)` rule is evaluated by the SAME engine that
1267        // evaluates `bash(...)`/`write(...)` at the dispatch gate — not by
1268        // a second matcher over a second list. `None` when the permissions
1269        // module is off, which is byte-identical to before.
1270        permission_rules: config.permissions_enabled.then(|| {
1271            std::sync::Arc::new(crate::permissions::RuleSet {
1272                deny: config.tool_deny_patterns.clone(),
1273                ask: config.permissions_ask_patterns.clone(),
1274                allow: config.tool_allow_patterns.clone(),
1275            })
1276        }),
1277        // P4e (S3.1 `core.tools.bash.timeout_secs`, S14): folds the `bash`
1278        // `ToolOverride`'s `timeout_secs`, if set, into the context every
1279        // `BashTool::execute` call receives -- `None` (no override
1280        // configured) is byte-identical to today's behavior.
1281        bash_timeout_secs: config
1282            .tool_overrides
1283            .get("bash")
1284            .and_then(|o| o.timeout_secs),
1285        write_observer,
1286        // P5-10 (§2 module 12): sourced from real config
1287        // (`capabilities.permissions.sandbox.{enabled,escalation,env_policy}`,
1288        // wired by `configfile::materialize_config`). `sandbox_approval_handler`
1289        // starts `None` here (no handler is installed yet at `Agent`
1290        // construction time) and is kept in sync by
1291        // `Agent::set_permissions_approval_handler` — see that method's doc
1292        // comment.
1293        sandbox_os_enabled: config.sandbox_os_enabled,
1294        sandbox_escalation: config.sandbox_escalation,
1295        sandbox_env_policy: config.sandbox_env_policy,
1296        sandbox_approval_handler: None,
1297        // BP-3: both handler seams start `None` (nothing is installed at
1298        // construction time) and are filled by
1299        // `Agent::set_permissions_approval_handler` /
1300        // `Agent::set_user_question_handler`, exactly like
1301        // `sandbox_approval_handler` above. The two shared states are
1302        // always present but inert: plan mode starts off (contributing no
1303        // rules), and the budget starts unpublished.
1304        question_handler: None,
1305        approval_handler: None,
1306        plan_mode: std::sync::Arc::new(crate::tools::PlanModeState::new()),
1307        context_budget: std::sync::Arc::new(crate::tools::ContextBudget::new()),
1308        // BP-8 (catalog:156): the shared plan the agent journals and
1309        // persists. Always present, empty and inert until `update_plan`
1310        // writes one.
1311        plan: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
1312    };
1313    (ctx, checkpoint_observer, lsp_manager)
1314}
1315
1316/// P4b (§1.4/§3.1, catalog §4a "Global/user-level instruction file tier"):
1317/// where the user/global instruction tier lives — `$SUPERCODE_HOME`, else
1318/// `$XDG_CONFIG_HOME/supercode`, else `~/.config/supercode`. Deliberately
1319/// duplicates `crates/cli/src/userconfig.rs::config_home`'s exact precedence
1320/// rather than depending on the `cli` crate from `core` (wrong dependency
1321/// direction — `cli` depends on `core`, never the reverse). `pub(crate)`:
1322/// also the DEFAULT shadow-store root `crate::checkpoint::observer_for_config`
1323/// (P5-9) derives from when `Config::checkpoint_dir` is unset — one
1324/// `$SUPERCODE_HOME` resolver, not a second hand-rolled one.
1325pub(crate) fn global_instructions_dir() -> std::path::PathBuf {
1326    if let Ok(h) = std::env::var("SUPERCODE_HOME") {
1327        if !h.is_empty() {
1328            return std::path::PathBuf::from(h);
1329        }
1330    }
1331    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1332        if !xdg.is_empty() {
1333            return std::path::PathBuf::from(xdg).join("supercode");
1334        }
1335    }
1336    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
1337    std::path::PathBuf::from(home)
1338        .join(".config")
1339        .join("supercode")
1340}
1341
1342/// P4b (§1.4, catalog §4a "Instruction imports"): is `rel` (an `@`-import
1343/// target found inside a PROJECT-sourced instruction file) LEXICALLY safe to
1344/// resolve? Mirrors `configfile::is_safe_project_dir`'s posture (LOW-1
1345/// precedent): rejects absolute paths, `~`-relative paths, and any `..`
1346/// component — an untrusted repo's own CLAUDE.md/AGENTS.md must not be able
1347/// to `@import` its way to an arbitrary file on disk (e.g. `@/etc/passwd`,
1348/// `@../../.ssh/id_rsa`). Global-tier files (the user's own machine, same
1349/// trust level as the user's shell) are NOT run through this check.
1350///
1351/// This is a cheap PRE-FILTER only — it operates on the literal token text
1352/// and cannot see through a symlink committed in the repo whose *target*
1353/// escapes the root while the *link itself* has a clean, traversal-free
1354/// relative name (e.g. `@link.md` where `link.md -> /etc/passwd`). See
1355/// [`import_target_is_contained`] for the canonicalizing check that closes
1356/// that gap; the project-scoped resolution path runs both.
1357fn import_path_is_safe(rel: &str) -> bool {
1358    if rel.is_empty() || rel.contains('\0') {
1359        return false;
1360    }
1361    let path = std::path::Path::new(rel);
1362    if path.is_absolute() || rel.starts_with('~') {
1363        return false;
1364    }
1365    !path
1366        .components()
1367        .any(|c| matches!(c, std::path::Component::ParentDir))
1368}
1369
1370/// P4b security fix (Fable-5 review, MEDIUM: symlink bypass of the
1371/// project-scoped `@`-import boundary): does `candidate` — after resolving
1372/// symlinks — stay inside `root` — also after resolving symlinks? This is
1373/// what actually enforces [`import_path_is_safe`]'s doc-comment guarantee
1374/// ("must not be able to `@import` its way to an arbitrary file on disk"):
1375/// the lexical check alone rejects `@/etc/passwd` and `@../../secret`, but a
1376/// repo can commit a symlink (e.g. `link.md -> /etc/passwd`) whose own
1377/// relative name is perfectly clean, defeating a purely lexical check.
1378///
1379/// Both sides are canonicalized before the comparison — not just
1380/// `candidate` — because `root` itself can legitimately be a symlink (a
1381/// tempdir under macOS's `/tmp` -> `/private/tmp`, or any other symlinked
1382/// project checkout); comparing a canonicalized candidate against a
1383/// non-canonicalized root would falsely reject genuinely-in-root files.
1384///
1385/// Fails CLOSED: a `canonicalize()` failure (broken symlink, a target that
1386/// doesn't exist, a permission error) returns `false` — never inlined,
1387/// mirroring [`expand_instruction_imports`]'s existing "unreadable file ⇒
1388/// left as literal text" posture rather than panicking or defaulting open.
1389pub(crate) fn import_target_is_contained(
1390    candidate: &std::path::Path,
1391    root: &std::path::Path,
1392) -> bool {
1393    let (Ok(real_root), Ok(real_candidate)) = (
1394        std::fs::canonicalize(root),
1395        std::fs::canonicalize(candidate),
1396    ) else {
1397        return false;
1398    };
1399    real_candidate.starts_with(&real_root)
1400}
1401
1402/// P4b (§1.4/§3.1 `core.instruction_imports`, catalog:85): inline `@path`
1403/// import tokens found in `text` with the referenced file's own (trimmed)
1404/// content, resolved relative to `dir` (the directory the CONTAINING file
1405/// lives in — so a chain of imports each resolves relative to its own
1406/// location, not the original file's). `depth` bounds recursion (CC's own
1407/// default of 4, cited in the cc-parity preset) so a cyclical or
1408/// deeply-nested import chain can't blow the stack or loop forever.
1409/// `project_scoped` gates [`import_path_is_safe`] AND
1410/// [`import_target_is_contained`] — see their doc comments; `root` is the
1411/// containment boundary those checks canonicalize against (the SAME root
1412/// for every level of a nested import chain, even though `dir` itself walks
1413/// deeper with each level — an import three levels deep must still resolve
1414/// under the original project root, not merely under its own immediate
1415/// parent). Ignored when `!project_scoped` (the global/user tier, trusted,
1416/// unrestricted — see [`append_instruction_file`]'s doc comment).
1417/// Any token that isn't `@`-prefixed, doesn't resolve to a readable file, or
1418/// (project-scoped) fails the safety/containment check is left as literal
1419/// text — an import is best-effort, never a hard error that could make
1420/// instruction loading fail outright.
1421fn expand_instruction_imports(
1422    text: &str,
1423    dir: &std::path::Path,
1424    root: &std::path::Path,
1425    project_scoped: bool,
1426    depth: u8,
1427) -> String {
1428    if depth >= 4 {
1429        return text.to_string();
1430    }
1431    let mut out = String::with_capacity(text.len());
1432    for token in split_preserving_whitespace(text) {
1433        if let Some(rel) = token.strip_prefix('@') {
1434            if !rel.is_empty()
1435                && !rel.contains(char::is_whitespace)
1436                && (!project_scoped || import_path_is_safe(rel))
1437            {
1438                let candidate = dir.join(rel);
1439                if !project_scoped || import_target_is_contained(&candidate, root) {
1440                    if let Ok(imported) = std::fs::read_to_string(&candidate) {
1441                        let imported = imported.trim();
1442                        if !imported.is_empty() {
1443                            let imported_dir = candidate.parent().unwrap_or(dir);
1444                            out.push_str(&expand_instruction_imports(
1445                                imported,
1446                                imported_dir,
1447                                root,
1448                                project_scoped,
1449                                depth + 1,
1450                            ));
1451                            continue;
1452                        }
1453                    }
1454                }
1455            }
1456        }
1457        out.push_str(token);
1458    }
1459    out
1460}
1461
1462/// Split `text` into tokens that, concatenated, reproduce it exactly —
1463/// alternating runs of non-whitespace and whitespace. Used by
1464/// [`expand_instruction_imports`] so `@import` tokens can be located and
1465/// replaced without disturbing surrounding formatting/whitespace.
1466fn split_preserving_whitespace(text: &str) -> Vec<&str> {
1467    let mut out = Vec::new();
1468    let mut start = 0;
1469    let mut in_ws = None;
1470    for (i, c) in text.char_indices() {
1471        let ws = c.is_whitespace();
1472        match in_ws {
1473            None => in_ws = Some(ws),
1474            Some(prev) if prev != ws => {
1475                out.push(&text[start..i]);
1476                start = i;
1477                in_ws = Some(ws);
1478            }
1479            _ => {}
1480        }
1481    }
1482    if start < text.len() {
1483        out.push(&text[start..]);
1484    }
1485    out
1486}
1487
1488/// BP-4 (catalog:87 "Instruction-file hygiene controls", cc§2
1489/// `claudeMdExcludes`): whether `path` is excluded from instruction loading
1490/// by [`Config::project_doc_excludes`]. A pattern matches when it
1491/// [`crate::config::glob_match`]es the file's NAME (`CLAUDE.md`), its full
1492/// path, or its path relative to `root` — the three spellings cc's own
1493/// "glob/absolute-path list" accepts. Empty (the default) excludes nothing.
1494fn instruction_file_excluded(
1495    config: &Config,
1496    path: &std::path::Path,
1497    root: &std::path::Path,
1498) -> bool {
1499    if config.project_doc_excludes.is_empty() {
1500        return false;
1501    }
1502    let full = path.to_string_lossy().to_string();
1503    let name = path
1504        .file_name()
1505        .map(|n| n.to_string_lossy().to_string())
1506        .unwrap_or_default();
1507    let rel = path
1508        .strip_prefix(root)
1509        .ok()
1510        .map(|p| p.to_string_lossy().to_string());
1511    config.project_doc_excludes.iter().any(|pat| {
1512        crate::config::glob_match(pat, &full)
1513            || crate::config::glob_match(pat, &name)
1514            || rel
1515                .as_deref()
1516                .is_some_and(|r| crate::config::glob_match(pat, r))
1517    })
1518}
1519
1520/// BP-4 (catalog:87, cc§2 "HTML comment stripping"): drop block-level
1521/// `<!-- … -->` spans from an instruction file's text so maintainer notes
1522/// cost no tokens, exactly as cc does before injection. Unterminated
1523/// openers drop the remainder (the same reading a markdown renderer takes).
1524/// Off by default ([`Config::project_doc_strip_comments`]) — cx does NOT
1525/// strip, so this is a per-preset hygiene lever, not a universal one.
1526fn strip_html_comments(text: &str) -> String {
1527    let mut out = String::with_capacity(text.len());
1528    let mut rest = text;
1529    while let Some(open) = rest.find("<!--") {
1530        out.push_str(&rest[..open]);
1531        match rest[open..].find("-->") {
1532            Some(close) => rest = &rest[open + close + 3..],
1533            None => return out,
1534        }
1535    }
1536    out.push_str(rest);
1537    out
1538}
1539
1540/// P4b (§1.4): append one instruction file's (trimmed, import-expanded)
1541/// content to `blob` as a labeled section, exactly like the pre-P4b inline
1542/// loop did — a no-op when `path` doesn't exist or is empty (the common
1543/// case). `project_scoped` distinguishes the project tier (imports bounded
1544/// to `root`, canonicalized-and-contained — see
1545/// [`import_target_is_contained`]) from the global tier (imports
1546/// unrestricted, same trust level as the user's own machine — `root` is
1547/// unused in that case). `root` is normally `path`'s own parent (the tier
1548/// root `path` was discovered under, e.g. an ancestor of `cwd` or an
1549/// `additional_dirs` entry) — see [`assemble_project_instructions`]'s call
1550/// sites.
1551///
1552/// BP-4 adds the hygiene controls (catalog:87): the exclude list
1553/// ([`instruction_file_excluded`]), HTML-comment stripping
1554/// ([`strip_html_comments`]) and the [`InstructionBudget`] —
1555/// [`Config::project_doc_max_bytes`] spent INCREMENTALLY as files are
1556/// concatenated root→cwd, which is how cx's own cap works on its root-down
1557/// concat, rather than one chop at the end (that chop would silently eat
1558/// the trailing per-file notices it had just written).
1559fn append_instruction_file(
1560    blob: &mut String,
1561    config: &Config,
1562    path: &std::path::Path,
1563    root: &std::path::Path,
1564    label: &str,
1565    project_scoped: bool,
1566    budget: &mut InstructionBudget,
1567) {
1568    if budget.exhausted() || instruction_file_excluded(config, path, root) {
1569        return;
1570    }
1571    let Ok(text) = std::fs::read_to_string(path) else {
1572        return;
1573    };
1574    let stripped;
1575    let text = if config.project_doc_strip_comments {
1576        stripped = strip_html_comments(&text);
1577        stripped.trim()
1578    } else {
1579        text.trim()
1580    };
1581    if text.is_empty() {
1582        return;
1583    }
1584    let dir = path.parent().unwrap_or(std::path::Path::new("."));
1585    let mut content = if config.instruction_imports {
1586        expand_instruction_imports(text, dir, root, project_scoped, 0)
1587    } else {
1588        text.to_string()
1589    };
1590    if !budget.take(&mut content) {
1591        return;
1592    }
1593    blob.push_str(&format!("\n\n# {label}\n{content}"));
1594}
1595
1596/// Truncate `s` to at most `max` BYTES, backing off to the nearest char
1597/// boundary — shared by the per-file and aggregate instruction caps.
1598fn truncate_at_char_boundary(s: &mut String, max: usize) {
1599    let mut end = max;
1600    while end > 0 && !s.is_char_boundary(end) {
1601        end -= 1;
1602    }
1603    s.truncate(end);
1604}
1605
1606/// BP-4 (catalog:87 "Instruction-file hygiene controls", cx§2
1607/// `project_doc_max_bytes`): the instruction-content byte budget, spent as
1608/// files are concatenated root→cwd.
1609///
1610/// `None` (the default, and cc-parity's explicit `= 0`) is uncapped, so
1611/// [`Self::take`] is a no-op and assembly is byte-identical to a config
1612/// that never heard of the cap. With a cap set, each file is truncated to
1613/// whatever budget REMAINS (per-file notice), and once the budget is gone
1614/// the remaining files are skipped entirely (aggregate notice, emitted once
1615/// by [`Self::aggregate_notice`]) — the total instruction CONTENT can
1616/// therefore never exceed the cap, and the notices survive because nothing
1617/// chops the assembled blob afterwards.
1618struct InstructionBudget {
1619    remaining: Option<usize>,
1620    hit: bool,
1621}
1622
1623impl InstructionBudget {
1624    fn new(config: &Config) -> Self {
1625        InstructionBudget {
1626            remaining: config.project_doc_max_bytes,
1627            hit: false,
1628        }
1629    }
1630
1631    /// True once the cap has consumed the whole budget — later files are
1632    /// skipped rather than partially appended.
1633    fn exhausted(&self) -> bool {
1634        self.remaining == Some(0)
1635    }
1636
1637    /// Charge `content` against the budget, truncating it (and appending a
1638    /// per-file notice) when it doesn't fit. Returns whether anything is
1639    /// left to append.
1640    fn take(&mut self, content: &mut String) -> bool {
1641        let Some(remaining) = self.remaining else {
1642            return true;
1643        };
1644        if content.len() <= remaining {
1645            self.remaining = Some(remaining - content.len());
1646            return true;
1647        }
1648        self.hit = true;
1649        self.remaining = Some(0);
1650        if remaining == 0 {
1651            return false;
1652        }
1653        truncate_at_char_boundary(content, remaining);
1654        content.push_str("\n[supercode: file truncated at core.project_doc_max_bytes]");
1655        true
1656    }
1657
1658    /// The one aggregate notice, appended after assembly when the cap bound
1659    /// anywhere — the statement that the assembled block is not the whole
1660    /// instruction set.
1661    fn aggregate_notice(&self) -> &'static str {
1662        if self.hit {
1663            "\n\n[supercode: instruction content truncated at core.project_doc_max_bytes]"
1664        } else {
1665            ""
1666        }
1667    }
1668}
1669
1670/// BP-4 (catalog:81 "Project instruction files w/ directory walk"; cc§2
1671/// "Directory-walk loading", cx§2 "walk project root (git root) down to
1672/// cwd"): the ancestor chain instruction files are discovered on, ordered
1673/// OUTERMOST FIRST so the nearest directory wins precedence by appearing
1674/// last in the concatenated blob (the root→cwd ordering both inventories
1675/// document).
1676///
1677/// The walk starts at [`Config::cwd`] and climbs until it has included the
1678/// project root [`crate::config::project_root_for`] identifies (`.git` by
1679/// default — cx's `project_root_markers`, §3.1), or until the filesystem
1680/// root, whichever comes first. [`MAX_INSTRUCTION_WALK_DEPTH`] bounds it
1681/// unconditionally, so a marker-less path deep under `/` can never turn
1682/// prompt assembly into an unbounded stat storm.
1683pub(crate) fn instruction_walk_roots(config: &Config) -> Vec<std::path::PathBuf> {
1684    // BP-9's shared answer to "where does the project stop?" — the same
1685    // walk the `env_context` git probe and the CLI's `.supercode.toml`
1686    // discovery use, so one `project_root_markers` value cannot mean three
1687    // different things. `None` (no marker anywhere, or an empty list) means
1688    // no root was found, and the climb below then stops at the filesystem
1689    // root under `MAX_INSTRUCTION_WALK_DEPTH`.
1690    let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers);
1691    let mut chain: Vec<std::path::PathBuf> = Vec::new();
1692    let mut dir = config.cwd.clone();
1693    loop {
1694        let at_root = root.as_deref() == Some(dir.as_path());
1695        chain.push(dir.clone());
1696        if at_root || chain.len() >= MAX_INSTRUCTION_WALK_DEPTH {
1697            break;
1698        }
1699        match dir.parent() {
1700            Some(parent) if parent != dir => dir = parent.to_path_buf(),
1701            _ => break,
1702        }
1703    }
1704    chain.reverse();
1705    chain
1706}
1707
1708/// Hard bound on [`instruction_walk_roots`]'s ancestor climb.
1709const MAX_INSTRUCTION_WALK_DEPTH: usize = 64;
1710
1711/// P4b (§1.4, obligation 4 assembly site): the full instruction-file blob —
1712/// global/user tier (catalog §4a "Global/user-level instruction file tier")
1713/// FIRST, then the project tier — capped by
1714/// [`Config::project_doc_max_bytes`] if set (catalog §4a "hygiene caps
1715/// (`project_doc_max_bytes` analog)").
1716///
1717/// BP-4 (catalog:81): the project tier is no longer `cwd` alone. It is the
1718/// ANCESTOR WALK [`instruction_walk_roots`] returns (cwd's chain up to the
1719/// git root, outermost first) followed by `additional_dirs` — root-first
1720/// ordering throughout, so the nearest directory wins by appearing later,
1721/// which is exactly how both cc§2 ("concatenated root→cwd, closest read
1722/// last") and cx§2 ("nearer-to-cwd wins by appearing later") describe their
1723/// own walks. `cwd` is the last element of the walk chain, so a config
1724/// whose cwd IS the project root assembles byte-identically to the pre-BP-4
1725/// loop.
1726/// BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
1727/// "Per-model base instructions": "the system prompt is selected per model
1728/// family from bundled markdown … the active `base_instructions` are
1729/// persisted verbatim into the rollout `session_meta`"): the base system
1730/// prompt for the model this config runs.
1731///
1732/// `[capabilities.model_catalog] base_prompts` maps a model-id glob to that
1733/// family's prompt; the most specific match wins
1734/// ([`crate::model_catalog::base_prompt_for`]). No table and no match both
1735/// give [`Config::system_prompt`] verbatim, so this is a no-op for every
1736/// config that does not set the table.
1737fn base_prompt_for_config(config: &Config) -> String {
1738    crate::model_catalog::base_prompt_for(&config.model_family_prompts, &config.model)
1739        .map(str::to_string)
1740        .unwrap_or_else(|| config.system_prompt.clone())
1741}
1742
1743fn assemble_project_instructions(config: &Config) -> String {
1744    let mut blob = String::new();
1745    let mut budget = InstructionBudget::new(config);
1746    let global_dir = global_instructions_dir();
1747    for name in ["CLAUDE.md", "AGENTS.md"] {
1748        append_instruction_file(
1749            &mut blob,
1750            config,
1751            &global_dir.join(name),
1752            // Global tier is trusted/unrestricted (project_scoped=false
1753            // below) — `root` is never consulted, but pass `global_dir`
1754            // rather than a bogus value for clarity.
1755            &global_dir,
1756            name,
1757            false,
1758            &mut budget,
1759        );
1760    }
1761    // BP-10 (catalog row "Project/workspace trust gate", cc§4/cx§4:
1762    // "Prompt before loading project-local config/code"): the PROJECT tier
1763    // is trust-gated. The global/user tier above is not — it is the user's
1764    // own machine, the same trust level as their shell, exactly as
1765    // `append_instruction_file`'s `project_scoped = false` argument
1766    // already says.
1767    //
1768    // `crate::trust::is_trusted` asks the `Config::trust_handler` door
1769    // once per project and records the answer; with no door installed
1770    // `TrustSurface::Instructions` resolves to LOADED, which is both the
1771    // pre-BP-10 behavior and what a headless run of either upstream
1772    // harness does — see `crate::trust`'s doc comment for why the
1773    // undecided answer differs between text and code.
1774    if !crate::trust::is_trusted(config, crate::trust::TrustSurface::Instructions) {
1775        blob.push_str(
1776            "\n[project instruction files were not loaded: this workspace is not trusted              (capabilities.trust)]\n",
1777        );
1778        blob.push_str(budget.aggregate_notice());
1779        return blob;
1780    }
1781    let walk = instruction_walk_roots(config);
1782    for root in walk.iter().chain(config.additional_dirs.iter()) {
1783        for name in ["CLAUDE.md", "AGENTS.md"] {
1784            append_instruction_file(
1785                &mut blob,
1786                config,
1787                &root.join(name),
1788                // Project tier: `@`-imports from THIS file must stay under
1789                // THIS root (canonicalized) — see
1790                // `import_target_is_contained`.
1791                root,
1792                name,
1793                true,
1794                &mut budget,
1795            );
1796        }
1797        // A repository-native agent package is an additional project
1798        // instruction tier. It is subject to the same `project_context`
1799        // switch, import containment, and aggregate byte cap as root
1800        // AGENTS.md/CLAUDE.md; loading it never executes package code.
1801        for path in crate::agent_package::workspace_package_instruction_files(root) {
1802            append_instruction_file(
1803                &mut blob,
1804                config,
1805                &path,
1806                root,
1807                "Supercode agent package instructions",
1808                true,
1809                &mut budget,
1810            );
1811        }
1812    }
1813    blob.push_str(budget.aggregate_notice());
1814    blob
1815}
1816
1817/// P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment context block
1818/// injection"): cwd, platform, date, and a best-effort git branch/dirty
1819/// status (silently absent when `cwd` isn't a git repo or `git` isn't on
1820/// `PATH` — never blocks agent construction).
1821///
1822/// BP-4 (catalog:90): plus the APPROVAL/SANDBOX POLICY line the row's own
1823/// semantics name ("cwd/git/platform/date/**policy**") and cx's
1824/// `<environment_context>` supplies — the model is told which approval mode
1825/// and which filesystem confinement it is operating under, which is what
1826/// makes "ask before you do X" instructions legible to it. The block is
1827/// re-derivable at any moment from `config` alone, which is what lets
1828/// [`Agent::refresh_env_context`] re-emit it mid-session on change.
1829/// BP-6 (catalog D2 "Skills (progressive-disclosure packages)", §1.4
1830/// obligation 4): the `# Skills` prompt section — the discovered SKILL.md
1831/// packages' names and descriptions, plus the `[core.prompts]` template
1832/// names, and NOTHING else. A skill's body is deliberately absent: it costs
1833/// its tokens only when something actually invokes it (`docs:skills`
1834/// "body loads only when used"; cx§7; pi§2 "progressive disclosure").
1835///
1836/// A skill whose frontmatter hides it from the model (`enabled: false`,
1837/// `disable-model-invocation: true`) is left OUT of the index while staying
1838/// user-invocable — cc§7 "Invocation control", pi§2.
1839///
1840/// Empty string when there is nothing to list, so an agent with neither
1841/// skills nor templates keeps the prompt it had before this existed.
1842fn skills_prompt_section(config: &Config, skills: &[crate::skills::LoopSkill]) -> String {
1843    let listed: Vec<&crate::skills::LoopSkill> = skills
1844        .iter()
1845        .filter(|skill| skill.model_invocable)
1846        .collect();
1847    let mut templates: Vec<&str> = config.prompts.keys().map(String::as_str).collect();
1848    templates.sort_unstable();
1849    if listed.is_empty() && templates.is_empty() {
1850        return String::new();
1851    }
1852    let mut out = String::from("\n\n# Skills\n");
1853    if !listed.is_empty() {
1854        out.push_str(
1855            "Installed skill packages. Only each skill's name and description are listed \
1856             here; call the `skill` tool with a name below to load that skill's full \
1857             instructions when it applies, then follow them.\n",
1858        );
1859        for skill in listed {
1860            out.push_str(&skill.index_line());
1861            out.push('\n');
1862        }
1863    }
1864    if !templates.is_empty() {
1865        if !out.ends_with("# Skills\n") {
1866            out.push('\n');
1867        }
1868        out.push_str("Prompt templates (invoke via `/name args`):\n");
1869        for name in templates {
1870            out.push_str(&format!("- {name}\n"));
1871        }
1872    }
1873    out
1874}
1875
1876fn env_context_block(config: &Config) -> String {
1877    let mut lines = vec![
1878        format!("cwd: {}", config.cwd.display()),
1879        format!("platform: {}", std::env::consts::OS),
1880        format!(
1881            "date: {}",
1882            crate::sidecar::now_rfc3339().get(..10).unwrap_or("")
1883        ),
1884        format!(
1885            "approval policy: {} · sandbox: {}",
1886            approval_policy_label(config.approval),
1887            sandbox_policy_label(config.sandbox),
1888        ),
1889    ];
1890    // BP-9 (§3.1 `core.project_root_markers`, catalog:232): the git probe
1891    // runs at the PROJECT ROOT the markers define, not at whatever
1892    // subdirectory the process happens to sit in — the marker knob's whole
1893    // job is deciding where "the project" starts. Falls back to `cwd` when
1894    // no ancestor carries a marker (or the list is empty), which is
1895    // byte-identical to the pre-BP-9 behavior.
1896    let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers)
1897        .unwrap_or_else(|| config.cwd.clone());
1898    if let Some(status) = env_context_git_status(&root) {
1899        lines.push(status);
1900    }
1901    format!("\n\n# Environment\n{}", lines.join("\n"))
1902}
1903
1904/// The `[capabilities.permissions] approval` spelling of a policy — the same
1905/// token the config schema accepts (`configfile::parse_approval_str`), so
1906/// the block reports the policy in the vocabulary the user configured it in.
1907fn approval_policy_label(policy: crate::config::ApprovalPolicy) -> &'static str {
1908    match policy {
1909        crate::config::ApprovalPolicy::Never => "never",
1910        crate::config::ApprovalPolicy::OnRequest => "on-request",
1911        crate::config::ApprovalPolicy::Untrusted => "untrusted",
1912        crate::config::ApprovalPolicy::ModelRequested => "model-requested",
1913    }
1914}
1915
1916/// The `[capabilities.permissions] sandbox` spelling of a tier — see
1917/// [`approval_policy_label`].
1918fn sandbox_policy_label(policy: crate::tools::SandboxPolicy) -> &'static str {
1919    match policy {
1920        crate::tools::SandboxPolicy::ReadOnly => "read-only",
1921        crate::tools::SandboxPolicy::WorkspaceWrite => "workspace-write",
1922        crate::tools::SandboxPolicy::DangerFullAccess => "danger-full-access",
1923    }
1924}
1925
1926/// Best-effort `git branch (dirty|clean)` for [`env_context_block`]. `None`
1927/// on anything short of a clean success (not a repo, `git` missing, a
1928/// detached/errored state) — this is informational context, never worth
1929/// failing agent construction over.
1930fn env_context_git_status(cwd: &std::path::Path) -> Option<String> {
1931    let branch_out = std::process::Command::new("git")
1932        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1933        .current_dir(cwd)
1934        .output()
1935        .ok()?;
1936    if !branch_out.status.success() {
1937        return None;
1938    }
1939    let branch = String::from_utf8_lossy(&branch_out.stdout)
1940        .trim()
1941        .to_string();
1942    if branch.is_empty() {
1943        return None;
1944    }
1945    let dirty = std::process::Command::new("git")
1946        .args(["status", "--porcelain"])
1947        .current_dir(cwd)
1948        .output()
1949        .ok()
1950        .map(|o| !o.stdout.is_empty())
1951        .unwrap_or(false);
1952    Some(format!(
1953        "git branch: {branch} ({})",
1954        if dirty { "dirty" } else { "clean" }
1955    ))
1956}
1957
1958impl Agent {
1959    /// Build an agent backed by an OpenAI-compatible endpoint (OpenRouter by
1960    /// default). The API key is taken from [`Config::api_key`], then
1961    /// [`Config::api_key_cmd`] (P4: a credential-helper command, run via the
1962    /// shell — see `run_api_key_cmd`), then the configured environment
1963    /// variable ([`Config::api_key_env`]).
1964    pub fn new(config: Config) -> Result<Self> {
1965        let api_key = match &config.api_key {
1966            Some(k) if !k.is_empty() => k.clone(),
1967            // BP-9: the ARGV helper (`core.api_key_command`) is consulted
1968            // first — it is the form with no shell in the path, so a config
1969            // that sets both gets the one with fewer ways to surprise its
1970            // author. Empty/failed → fall through, same as `api_key_cmd`.
1971            _ => match config
1972                .api_key_command
1973                .as_deref()
1974                .filter(|argv| !argv.is_empty())
1975                .map(run_api_key_command)
1976                .filter(|k| !k.is_empty())
1977                .or_else(|| {
1978                    config
1979                        .api_key_cmd
1980                        .as_deref()
1981                        .filter(|c| !c.is_empty())
1982                        .map(run_api_key_cmd)
1983                }) {
1984                // P4 (§1.8 credential-helper indirection, D6 row): the
1985                // helper ran and produced a non-empty key — use it. A
1986                // failed/empty helper falls through to `api_key_env` rather
1987                // than erroring outright, same "try the next source"
1988                // posture as every other layer in this resolution chain.
1989                Some(k) if !k.is_empty() => k,
1990                _ => std::env::var(&config.api_key_env)
1991                    .ok()
1992                    .filter(|k| !k.is_empty())
1993                    .ok_or_else(|| Error::MissingApiKey(config.api_key_env.clone()))?,
1994            },
1995        };
1996        // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): `Config.retry_*` now
1997        // reaches the pre-existing transport-layer retry mechanism (see
1998        // `provider::HttpOptions::from_retry_config`'s doc comment for the
1999        // exact "byte-identical when unset" contract).
2000        let http_options = provider::HttpOptions::from_retry_config(
2001            config.retry_enabled,
2002            config.retry_max_retries,
2003            config.retry_base_delay_ms,
2004        );
2005        // BP-7 (catalog §4a "Turn/budget caps"): a spend cap armed against
2006        // a model this build cannot price is refused HERE rather than
2007        // accepted and silently never enforced. See
2008        // `Config::max_budget_usd`.
2009        if config.max_budget_usd.is_some_and(|b| b > 0.0)
2010            && crate::pricing::resolve(
2011                &config.model,
2012                config.price_input_per_mtok,
2013                config.price_output_per_mtok,
2014            )
2015            .is_none()
2016        {
2017            return Err(Error::UnpriceableBudget {
2018                model: config.model.clone(),
2019            });
2020        }
2021        // BP-7 (catalog §4a "Auto-retry on transient provider errors"): the
2022        // shared log the transport's retry loop reports into and
2023        // `Self::run_loop` drains after every completion.
2024        let retry_log = std::sync::Arc::new(crate::provider::RetryLog::default());
2025        let provider = OpenAiProvider::new_with_options(
2026            config.base_url.clone(),
2027            api_key,
2028            config.extra_headers.clone(),
2029            http_options,
2030        )
2031        .with_retry_log(retry_log.clone());
2032        // P3 (design §5.2): `ToolRegistry::from_config` replaces the
2033        // unconditional `with_builtins()` call — a no-op when
2034        // `config.module_registry` is off (the default, §5.3 risk 2).
2035        let registry = ToolRegistry::from_config(&config);
2036        let mut agent = Self::with_parts(config, Box::new(provider), registry);
2037        agent.retry_log = retry_log;
2038        Ok(agent)
2039    }
2040
2041    /// Build an agent with an explicit provider and the built-in tools. Handy
2042    /// for tests (inject a mock provider) or custom transports.
2043    pub fn with_provider(config: Config, provider: Box<dyn Provider>) -> Self {
2044        let registry = ToolRegistry::from_config(&config);
2045        Self::with_parts(config, provider, registry)
2046    }
2047
2048    /// Build an agent from all three parts.
2049    pub fn with_parts(
2050        mut config: Config,
2051        provider: Box<dyn Provider>,
2052        mut registry: ToolRegistry,
2053    ) -> Self {
2054        // P5-12 (§2 module 18 `plugins`, D-10): register every trusted,
2055        // loaded plugin's declared tools — the same "unconditional, config-
2056        // gated" wiring `build_tool_context` just below gives
2057        // checkpoint/formatters/lsp. `crate::plugins::register_into` is a
2058        // true no-op (no filesystem read, no subprocess) whenever
2059        // `config.plugins_enabled` is `false` (the default) — byte-identical
2060        // to before this module existed. Runs here (the one tail every
2061        // `Agent` construction path funnels through — `new`/`with_provider`
2062        // both call this) rather than in `ToolRegistry::from_config`, so it
2063        // is NOT entangled with that function's unrelated `module_registry`
2064        // experimental gate.
2065        crate::plugins::register_into(&config, &mut registry);
2066        let (mut ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
2067        // Auto-load project context files (CLAUDE.md / AGENTS.md) from the
2068        // working directory (and any extra roots), appending them to the system
2069        // prompt — the analog of how Claude Code / Codex discover them.
2070        // P4b (§1.4): also the global/user tier + instruction imports + the
2071        // `project_doc_max_bytes` hygiene cap — see `assemble_project_instructions`.
2072        // BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
2073        // "Per-model base instructions"): the base prompt is chosen for the
2074        // model in force, not fixed before the model is known — the exact
2075        // residue the ledger row named. `base_prompt_for_config` is
2076        // `config.system_prompt` verbatim for every config that sets no
2077        // family table, so this is a no-op by default.
2078        let base_prompt_live = base_prompt_for_config(&config);
2079        // BP-5 (catalog D2 "Output style / personality module"): a custom
2080        // style may REPLACE the base coding instructions rather than append
2081        // to them (cc§7 `keep-coding-instructions`); every other style is
2082        // appended at the end of the assembled prompt, below.
2083        let output_style = crate::output_style::resolve(&config);
2084        let mut system = match output_style.as_ref() {
2085            Some(style) if style.replaces_base => style.text.clone(),
2086            _ => base_prompt_live.clone(),
2087        };
2088        if config.load_project_context {
2089            system.push_str(&assemble_project_instructions(&config));
2090        }
2091        // BP-5 (catalog D2 "Path-scoped rules", cc§2 `.claude/rules`): the
2092        // UNSCOPED rules join the instruction blob here. A rule carrying a
2093        // `paths:` selector deliberately does not — it waits for a tool to
2094        // touch a matching file (`tools::builtins::path_rules_notice`).
2095        let path_rules = crate::path_rules::load(&config);
2096        system.push_str(&crate::path_rules::always_on_text(&path_rules));
2097        // P4b (§1.4/§3.1 `core.env_context`, catalog §4a "Environment
2098        // context block injection"): `false` (the default) is a no-op —
2099        // byte-identical to today's behavior. BP-4 keeps the rendered block
2100        // on the agent (`env_context_live`) so `refresh_env_context` can
2101        // find and REPLACE exactly this text when cwd/policy/branch move,
2102        // rather than leaving a stale block in the prompt forever.
2103        let env_context_live = if config.env_context {
2104            let block = env_context_block(&config);
2105            system.push_str(&block);
2106            Some(block)
2107        } else {
2108            None
2109        };
2110        // P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
2111        // context-injection blocks"): same assembly site, right after
2112        // `env_context`. `false` (the default) is a no-op — byte-identical
2113        // to today's behavior. BP-4 routes it through
2114        // `crate::context_injection`, so the gate now delivers the built-in
2115        // ambient blocks the row is about (and stays extensible at runtime
2116        // through `Self::inject_context_block`) instead of only whatever
2117        // static list an embedder happened to populate.
2118        system.push_str(&crate::context_injection::assemble(&config, &[]));
2119        // P3 (design §5.2, §1.4 obligation 4, D-7): the skills prompt
2120        // section is a MODULE-GATED prompt section, the design's own
2121        // illustration of "a disabled module contributes no prompt
2122        // sections" — only assembled at all under
2123        // `[experimental] module_registry = true` (§5.3 risk 2: flag-off is
2124        // byte-for-byte today's behavior, and today's behavior never emits
2125        // this section, since it doesn't exist pre-P3). Gated further by
2126        // D-7 itself: `core.skills` requires a read pathway (`read_file` or
2127        // `bash`) — absent either, no section is appended, matching the
2128        // hard-dependency shape `configfile::validate_modules` enforces at
2129        // resolve time.
2130        //
2131        // BP-6 (catalog D2 "Skills (progressive-disclosure packages)"): the
2132        // section is now the discovered SKILL.md INDEX — each package's
2133        // frontmatter `name` and `description`, nothing else. A body is
2134        // never assembled here; it is read on invocation only, which is
2135        // what "progressive disclosure" means. The `[core.prompts]`
2136        // template names keep their own sub-list below it.
2137        let skills = crate::skills::load_for_config(&config);
2138        if config.module_registry && config.skills_enabled {
2139            let has_read_pathway = config
2140                .core_tools_enabled
2141                .iter()
2142                .any(|t| t == "read_file" || t == "bash");
2143            if has_read_pathway {
2144                system.push_str(&skills_prompt_section(&config, &skills));
2145            }
2146        }
2147        // BP-5: the style layer lands LAST, where cc puts it ("output styles
2148        // append custom instructions to the END of the system prompt").
2149        // Empty for a neutral style (`default`/`none`) and for a style that
2150        // already replaced the base above.
2151        if let Some(style) = output_style.as_ref().filter(|s| !s.replaces_base) {
2152            system.push_str(&style.section());
2153        }
2154        // BP-5: the SCOPED rules travel with the tool context, which is
2155        // where a "a tool touched a matching file" event can see them.
2156        ctx.path_rules = std::sync::Arc::new(path_rules);
2157        let history = vec![ChatMessage::system(system)];
2158        // P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): captured
2159        // once here, alongside `env_context`'s own git probe — `false` (the
2160        // default) is a no-op, byte-identical to today's behavior.
2161        let git_metadata = if config.session_git_metadata {
2162            crate::git_metadata::capture(&config.cwd, now_ms())
2163        } else {
2164            None
2165        };
2166        // BP-7 (catalog §4a "Named agent definitions as data"): discover
2167        // `<cwd>/.claude/agents/*.md` for EVERY harness that turns the
2168        // subagents module on, not only the Claude emulate/resume path —
2169        // that restriction was the second half of the ledger row's
2170        // residue.
2171        merge_project_agent_definitions(&mut config);
2172        // P5-3: captured before `config` moves into the literal below (a
2173        // `usize` field READ, not a move, but it must happen before the
2174        // `config` shorthand field consumes the binding).
2175        let subagent_depth = config.subagent_depth;
2176        // BP-5 (catalog D2 "Shell-output injection in templates/skills"):
2177        // the authorization every `` !`cmd` `` in a skill/command body is
2178        // evaluated under — this config's own permission rules, resolved
2179        // once. Disabled unless `[core.skills] shell_injection` is on.
2180        let shell_injection = crate::skills::ShellInjection::from_config(&config);
2181        // BP-8 (catalog:151): `[capabilities.session_tree] enabled` finally
2182        // has a reader. An armed tree starts empty and grows one node per
2183        // recorded message — the degenerate single-path case, byte-for-byte
2184        // the same conversation, until a rewind or branch actually forks it.
2185        let session_tree = if config.session_tree_enabled {
2186            Some(crate::session_tree::SessionTree::new())
2187        } else {
2188            None
2189        };
2190        // BP-7: resolved once here so the request path never re-does the
2191        // lookup, and so `Self::model_price` is `None` exactly when this
2192        // build cannot price the model.
2193        let model_price = crate::pricing::resolve(
2194            &config.model,
2195            config.price_input_per_mtok,
2196            config.price_output_per_mtok,
2197        );
2198        // BP-10: same reason — built before `config` moves into the
2199        // literal. `Config::permissions_approvals_persist` off (the
2200        // default) makes this the pre-BP-10 in-memory cache and touches no
2201        // filesystem.
2202        let permissions_approval_cache = crate::permissions::cache_for_config(&config);
2203        Agent {
2204            config,
2205            provider: std::sync::Arc::from(provider),
2206            registry,
2207            history,
2208            ctx,
2209            total_output_tokens: 0,
2210            activated_tools: HashSet::new(),
2211            recorder: None,
2212            journal: None,
2213            session_tree,
2214            rewind_undo: Vec::new(),
2215            journaled_plan: Vec::new(),
2216            reduction_policy: None,
2217            reduction_log: ReductionLog::default(),
2218            imported_prefix_len: None,
2219            compacting_manually: false,
2220            env_context_live,
2221            base_prompt_live,
2222            shell_injection,
2223            spliced_context_blocks: Vec::new(),
2224            span_summarizer: None,
2225            last_tool_schema_tier_signature: None,
2226            context_limit: None,
2227            requests_issued: false,
2228            last_cache_activity_ms: None,
2229            cache_established: false,
2230            pending_cache_turn: (false, false, None),
2231            session_titler: None,
2232            usage_log: Vec::new(),
2233            turn_index: 0,
2234            turn_records: Vec::new(),
2235            retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
2236            model_price,
2237            total_cost_usd: 0.0,
2238            total_steps: 0,
2239            reaped_subagents: std::collections::HashMap::new(),
2240            goal: None,
2241            steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
2242            follow_up_queue: std::collections::VecDeque::new(),
2243            doom_loop_last_call: None,
2244            doom_loop_streak: 0,
2245            model_change_log: Vec::new(),
2246            git_metadata,
2247            permissions_approval_cache,
2248            permissions_approval_handler: None,
2249            mcp_prompts: std::collections::HashMap::new(),
2250            skills,
2251            subagent_depth,
2252            subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2253            background_subagents: std::collections::HashMap::new(),
2254            pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2255            child_approval_handler_factory: None,
2256            subagent_store: None,
2257            claude_runtime_manifest: None,
2258            background_jobs: std::collections::HashMap::new(),
2259            background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
2260                0,
2261            )),
2262            checkpoint_observer,
2263            lsp_manager,
2264        }
2265    }
2266
2267    /// A handle to this agent's model transport, for sharing with subagents.
2268    pub fn provider_arc(&self) -> std::sync::Arc<dyn Provider> {
2269        self.provider.clone()
2270    }
2271
2272    /// Read-only access to this agent's resolved [`Config`] — e.g. so a
2273    /// caller (`crates/cli`'s `attach_mcp`) can consult
2274    /// [`Config::module_registry`]/[`Config::module_activation`] AFTER
2275    /// construction without having to separately thread the config through
2276    /// every call site that builds an `Agent` and later needs it again.
2277    /// Same trust boundary as every other already-public `Agent` accessor
2278    /// (`history`, `provider_arc`) — the caller is the same process that
2279    /// built this `Config` in the first place, not a new exposure surface.
2280    pub fn config(&self) -> &Config {
2281        &self.config
2282    }
2283
2284    /// P5-9 (§2 module 20 `checkpoint`): this agent's checkpoint engine, if
2285    /// `Config::checkpoint_enabled` is `true` and the shadow store opened
2286    /// successfully — `None` otherwise (the default-off case, or a
2287    /// graceful-degrade after an I/O failure). A caller (CLI/TUI/embedder)
2288    /// uses this to `list`/`turn_diff`/`restore` WITHOUT re-deriving the
2289    /// shadow-store root itself. Deliberately named `checkpoint_observer`,
2290    /// not `checkpoint` — [`Self::checkpoint`] already names the unrelated
2291    /// in-memory conversation-position marker (see that method's doc
2292    /// comment).
2293    pub fn checkpoint_observer(&self) -> Option<&crate::checkpoint::CheckpointObserver> {
2294        self.checkpoint_observer.as_deref()
2295    }
2296
2297    /// P5-11 (§2 module 28 `lsp`): this agent's LSP server registry, if
2298    /// `Config::lsp_enabled` is `true` — `None` otherwise (the default-off
2299    /// case). `impl Drop for Agent` already covers production teardown via
2300    /// [`crate::lsp::LspManager::kill_all_sync`] (a real, group-killing OS
2301    /// process kill — see `crate::lsp`'s module doc). This accessor exists
2302    /// for an OPTIONAL caller (CLI/TUI/embedder) that manages its own
2303    /// `Agent` lifecycle and additionally wants to reach
2304    /// [`crate::lsp::LspManager::shutdown_all`] for a graceful LSP
2305    /// `shutdown`/`exit` handshake BEFORE dropping the agent — nothing
2306    /// calls `shutdown_all` automatically today.
2307    pub fn lsp_manager(&self) -> Option<&crate::lsp::LspManager> {
2308        self.lsp_manager.as_deref()
2309    }
2310
2311    /// Spawn a subagent that shares this agent's model transport, runs `task`
2312    /// to completion with its own fresh conversation (seeded with `system`), and
2313    /// returns its final answer. The analog of `Agent` / `spawn_agent`.
2314    pub async fn run_subagent(
2315        &self,
2316        system: impl Into<String>,
2317        task: impl Into<String>,
2318    ) -> Result<String> {
2319        let mut sub_config = Config::builder()
2320            .model(self.config.model.clone())
2321            .system_prompt(system)
2322            .cwd(self.config.cwd.clone())
2323            .sandbox(self.config.sandbox)
2324            .max_iterations(self.config.max_iterations)
2325            .build();
2326        sub_config.base_url = self.config.base_url.clone();
2327        let mut sub = Agent::with_provider_arc(sub_config, self.provider.clone());
2328        sub.send(task).await
2329    }
2330
2331    /// Like [`Self::with_provider`] but sharing an existing transport handle.
2332    pub fn with_provider_arc(mut config: Config, provider: std::sync::Arc<dyn Provider>) -> Self {
2333        let (ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
2334        let history = vec![ChatMessage::system(config.system_prompt.clone())];
2335        // P3 (design §5.2): see the `Self::new` doc note — a no-op when
2336        // `config.module_registry` is off (the default).
2337        let mut registry = ToolRegistry::from_config(&config);
2338        // P5-12: see `Self::with_parts`'s identical call — a no-op when
2339        // `config.plugins_enabled` is `false` (the default).
2340        crate::plugins::register_into(&config, &mut registry);
2341        // P4e: see `Self::with_parts`'s identical capture.
2342        let git_metadata = if config.session_git_metadata {
2343            crate::git_metadata::capture(&config.cwd, now_ms())
2344        } else {
2345            None
2346        };
2347        // BP-7 (catalog §4a "Named agent definitions as data"): discover
2348        // `<cwd>/.claude/agents/*.md` for EVERY harness that turns the
2349        // subagents module on, not only the Claude emulate/resume path —
2350        // that restriction was the second half of the ledger row's
2351        // residue.
2352        merge_project_agent_definitions(&mut config);
2353        // P5-3: captured before `config` moves into the literal below (a
2354        // `usize` field READ, not a move, but it must happen before the
2355        // `config` shorthand field consumes the binding).
2356        let subagent_depth = config.subagent_depth;
2357        // BP-6: this constructor assembles no prompt sections at all (it
2358        // takes `config.system_prompt` verbatim), so there is no skills
2359        // INDEX here — but the discovered set still rides along, so an
2360        // explicit invocation (`/name`, `$slug`, the `skill` tool) resolves
2361        // the same packages the registry's own `skill` tool holds.
2362        let skills = crate::skills::load_for_config(&config);
2363        let base_prompt_live = config.system_prompt.clone();
2364        let shell_injection = crate::skills::ShellInjection::from_config(&config);
2365        // BP-8 (catalog:151): `[capabilities.session_tree] enabled` finally
2366        // has a reader. An armed tree starts empty and grows one node per
2367        // recorded message — the degenerate single-path case, byte-for-byte
2368        // the same conversation, until a rewind or branch actually forks it.
2369        let session_tree = if config.session_tree_enabled {
2370            Some(crate::session_tree::SessionTree::new())
2371        } else {
2372            None
2373        };
2374        // BP-7: resolved once here so the request path never re-does the
2375        // lookup, and so `Self::model_price` is `None` exactly when this
2376        // build cannot price the model.
2377        let model_price = crate::pricing::resolve(
2378            &config.model,
2379            config.price_input_per_mtok,
2380            config.price_output_per_mtok,
2381        );
2382        // BP-10: see the sibling constructor — built before `config` moves.
2383        let permissions_approval_cache = crate::permissions::cache_for_config(&config);
2384        Agent {
2385            config,
2386            provider,
2387            registry,
2388            history,
2389            ctx,
2390            total_output_tokens: 0,
2391            activated_tools: HashSet::new(),
2392            recorder: None,
2393            journal: None,
2394            session_tree,
2395            rewind_undo: Vec::new(),
2396            journaled_plan: Vec::new(),
2397            reduction_policy: None,
2398            reduction_log: ReductionLog::default(),
2399            imported_prefix_len: None,
2400            compacting_manually: false,
2401            env_context_live: None,
2402            // BP-5: this constructor assembles no prompt sections (see the
2403            // skills note above) — `config.system_prompt` IS the whole
2404            // system message, so that is what a later `set_model` would
2405            // have to replace.
2406            base_prompt_live,
2407            shell_injection,
2408            spliced_context_blocks: Vec::new(),
2409            span_summarizer: None,
2410            last_tool_schema_tier_signature: None,
2411            context_limit: None,
2412            requests_issued: false,
2413            last_cache_activity_ms: None,
2414            cache_established: false,
2415            pending_cache_turn: (false, false, None),
2416            session_titler: None,
2417            usage_log: Vec::new(),
2418            turn_index: 0,
2419            turn_records: Vec::new(),
2420            retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
2421            model_price,
2422            total_cost_usd: 0.0,
2423            total_steps: 0,
2424            reaped_subagents: std::collections::HashMap::new(),
2425            goal: None,
2426            steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
2427            follow_up_queue: std::collections::VecDeque::new(),
2428            doom_loop_last_call: None,
2429            doom_loop_streak: 0,
2430            model_change_log: Vec::new(),
2431            git_metadata,
2432            permissions_approval_cache,
2433            permissions_approval_handler: None,
2434            mcp_prompts: std::collections::HashMap::new(),
2435            skills,
2436            subagent_depth,
2437            subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2438            background_subagents: std::collections::HashMap::new(),
2439            pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2440            child_approval_handler_factory: None,
2441            subagent_store: None,
2442            claude_runtime_manifest: None,
2443            background_jobs: std::collections::HashMap::new(),
2444            background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
2445                0,
2446            )),
2447            checkpoint_observer,
2448            lsp_manager,
2449        }
2450    }
2451
2452    /// Run a prompt on a background task, returning a handle that resolves to
2453    /// the final answer (and the agent, so the caller can continue it). The
2454    /// analog of background/async agent runs.
2455    pub fn run_in_background(
2456        mut self,
2457        prompt: impl Into<String>,
2458    ) -> tokio::task::JoinHandle<(Self, Result<String>)>
2459    where
2460        Self: Send + 'static,
2461    {
2462        let prompt = prompt.into();
2463        tokio::spawn(async move {
2464            let result = self.send(prompt).await;
2465            (self, result)
2466        })
2467    }
2468
2469    /// Build an agent and seed it with a previously-recorded [`Session`] so it
2470    /// can continue where Claude Code or Codex left off.
2471    pub fn resume(config: Config, session: Session) -> Result<Self> {
2472        let mut agent = Agent::new(config)?;
2473        agent.load_session(session);
2474        Ok(agent)
2475    }
2476
2477    /// Like [`Self::resume`], but also begins recording (A2/A3): a fresh
2478    /// native-v2 sidecar is created at `sidecar_path` from `session` (header +
2479    /// `session.raw` verbatim — the imported prefix's own fidelity), and every
2480    /// subsequent turn this agent produces is appended to it at full fidelity,
2481    /// independent of whatever `cap_tool_output`/`maybe_compact` (D6) do to
2482    /// `history`.
2483    ///
2484    /// Invariant this establishes ONLY once [`Self::set_reduction_policy`] is
2485    /// also called (the D6/A7 supersession gate, `Self::run_loop`): at any
2486    /// instant, `Session::from_native_str(sidecar).messages` equals
2487    /// `session.messages` (the imported prefix) followed by every message
2488    /// appended since — i.e. `self.history()[1..]` (`history[0]` is this
2489    /// agent's own system prompt, per [`Self::load_session`]; it is never
2490    /// part of `session` and is never written to the sidecar). Recording
2491    /// alone (no policy) leaves the gate off: `cap_tool_output` still runs on
2492    /// oversized tool results, and `history` can diverge from the sidecar for
2493    /// them — honestly, via the notice's "full output in session sidecar"
2494    /// label, never silently.
2495    pub fn resume_recorded(
2496        config: Config,
2497        session: Session,
2498        sidecar_path: &std::path::Path,
2499    ) -> Result<Self> {
2500        let mut agent = Agent::new(config)?;
2501        let recorder = SidecarWriter::create(sidecar_path, &session)?;
2502        agent.load_session(session);
2503        agent.recorder = Some(recorder);
2504        Ok(agent)
2505    }
2506
2507    /// Install (or replace) this agent's sidecar recorder (A3).
2508    pub fn set_recorder(&mut self, w: SidecarWriter) {
2509        self.recorder = Some(w);
2510    }
2511
2512    // ---- BP-8: the append-only journal (catalog:150/152/154/156) --------
2513
2514    /// Install (or replace) this agent's append-only session journal — the
2515    /// durable, flush-per-record log of every message it produces plus
2516    /// every queue/rewind/plan operation performed on it. Installing one
2517    /// alone changes nothing about the conversation; it only makes the
2518    /// session survive a crash mid-turn.
2519    pub fn set_journal(&mut self, journal: crate::session_journal::SessionJournal) {
2520        self.journal = Some(std::sync::Arc::new(std::sync::Mutex::new(journal)));
2521    }
2522
2523    /// Whether an append-only journal is installed.
2524    pub fn has_journal(&self) -> bool {
2525        self.journal.is_some()
2526    }
2527
2528    /// Append one operation to the journal, if installed. Best-effort by
2529    /// design: losing a durability record must never fail the turn it
2530    /// describes, so the failure is logged and the loop continues — the
2531    /// same contract the compaction-marker `record` call keeps.
2532    fn journal_op(&self, op: crate::session_journal::JournalOp) {
2533        let Some(journal) = &self.journal else { return };
2534        let mut guard = journal
2535            .lock()
2536            .unwrap_or_else(std::sync::PoisonError::into_inner);
2537        if let Err(error) = guard.append(op) {
2538            tracing::warn!("failed to append a session-journal record: {error}");
2539        }
2540    }
2541
2542    /// Declare the durable view caught up: `<name>.jsonl` now holds
2543    /// `messages` messages and every journal record before this point is
2544    /// already in it. Everything journaled AFTER the last such record is
2545    /// exactly what a crash would have lost — see
2546    /// [`crate::session_journal::JournalState::unpersisted`].
2547    pub fn journal_checkpoint(&self, messages: usize) {
2548        self.journal_op(crate::session_journal::JournalOp::Checkpoint { messages });
2549    }
2550
2551    /// BP-13: record one per-turn usage entry in the append-only journal.
2552    pub fn journal_usage(&self, record: &crate::usage_log::UsageRecord) {
2553        self.journal_op(crate::session_journal::JournalOp::Usage {
2554            record: record.clone(),
2555        });
2556    }
2557
2558    /// BP-13: record one mid-session model change in the append-only
2559    /// journal — the ONE persisted home for a routing record (BP-8's
2560    /// journal), never a second file.
2561    pub fn journal_model_change(&self, record: &crate::model_change::ModelChangeRecord) {
2562        self.journal_op(crate::session_journal::JournalOp::ModelChange {
2563            record: record.clone(),
2564        });
2565    }
2566
2567    /// BP-8 (catalog:151): this session's conversation tree, when the
2568    /// module is on.
2569    pub fn session_tree(&self) -> Option<&crate::session_tree::SessionTree> {
2570        self.session_tree.as_ref()
2571    }
2572
2573    /// Install a tree loaded from the store (a resume), replacing whatever
2574    /// this agent built. A no-op when the module is off — a session whose
2575    /// preset does not enable `session_tree` must not acquire one through
2576    /// the back door of an old sidecar.
2577    pub fn set_session_tree(&mut self, tree: crate::session_tree::SessionTree) {
2578        if self.config.session_tree_enabled {
2579            self.session_tree = Some(tree);
2580        }
2581    }
2582
2583    /// BP-8: rebuild the tree from the current linear history — used after
2584    /// a resume that loaded a transcript but had no `.tree.json` to restore
2585    /// (every session recorded before the module was on).
2586    pub fn rebuild_session_tree_from_history(&mut self) {
2587        if !self.config.session_tree_enabled {
2588            return;
2589        }
2590        let linear: Vec<ChatMessage> = self.history.iter().skip(1).cloned().collect();
2591        self.session_tree = Some(crate::session_tree::SessionTree::from_linear(
2592            &linear,
2593            now_ms(),
2594        ));
2595    }
2596
2597    /// BP-8 (catalog:152 "Rewind/rollback conversation"): move THIS
2598    /// conversation back to an earlier point — the whole row, not the
2599    /// last-exchange special case [`Self::rewind_to`] serves and not
2600    /// `sessions fork --at`, which makes a different session.
2601    ///
2602    /// `keep` is a message count (index into `history`), so `keep = 1`
2603    /// leaves only the system message. Three things happen, in this order:
2604    ///
2605    /// 1. the removed tail is pushed onto an undo stack, so
2606    ///    [`Self::undo_rewind`] can put it back;
2607    /// 2. a [`crate::session_journal::JournalOp::Rewind`] record is
2608    ///    APPENDED — nothing is deleted from disk, so the rewound-away
2609    ///    messages remain recoverable from the log;
2610    /// 3. when the tree module is on, the active branch's leaf moves to the
2611    ///    node at `keep`, and the old leaf is preserved under a fresh
2612    ///    sibling branch — the next message appended forks there rather
2613    ///    than overwriting.
2614    ///
2615    /// Returns what it did. Rewinding to a point at or past the end is a
2616    /// no-op with `removed = 0`, never an error.
2617    pub fn rewind_conversation(&mut self, keep: usize) -> RewindOutcome {
2618        let keep = keep.max(1).min(self.history.len());
2619        let removed: Vec<ChatMessage> = self.history.split_off(keep);
2620        if removed.is_empty() {
2621            return RewindOutcome {
2622                kept: self.history.len(),
2623                removed: 0,
2624                preserved_branch: None,
2625            };
2626        }
2627        let removed_count = removed.len();
2628        self.rewind_undo.push(removed);
2629        // `keep` counts the system message; the journal records only
2630        // `history[1..]`, so its own view is one shorter.
2631        self.journal_op(crate::session_journal::JournalOp::Rewind { to: keep - 1 });
2632        let preserved_branch = self.session_tree.as_mut().and_then(|tree| {
2633            let path = tree.active_path().unwrap_or_default();
2634            // `keep - 1` messages remain after the system message, so the
2635            // new leaf is the node at index `keep - 2`.
2636            match keep.checked_sub(2).and_then(|i| path.get(i).cloned()) {
2637                Some(node) => tree.rewind(&node, now_ms()).ok().flatten(),
2638                None => None,
2639            }
2640        });
2641        RewindOutcome {
2642            kept: self.history.len(),
2643            removed: removed_count,
2644            preserved_branch,
2645        }
2646    }
2647
2648    /// BP-8: invert the most recent [`Self::rewind_conversation`] — the
2649    /// messages come back, and the inversion is itself an appended journal
2650    /// record. `false` when there is nothing to undo.
2651    pub fn undo_rewind(&mut self) -> bool {
2652        let Some(mut tail) = self.rewind_undo.pop() else {
2653            return false;
2654        };
2655        self.history.append(&mut tail);
2656        self.journal_op(crate::session_journal::JournalOp::Unrewind);
2657        if self.config.session_tree_enabled {
2658            self.rebuild_session_tree_from_history();
2659        }
2660        true
2661    }
2662
2663    /// BP-8 (catalog:150): append messages recovered from the journal
2664    /// after a crash — they were already recorded, so this deliberately
2665    /// does NOT re-journal them; it puts the live conversation back where
2666    /// the interrupted process left it.
2667    pub fn append_recovered_messages(&mut self, messages: &[ChatMessage]) {
2668        for msg in messages {
2669            if let Some(tree) = self.session_tree.as_mut() {
2670                tree.append_message(msg.clone(), now_ms());
2671            }
2672            self.history.push(msg.clone());
2673        }
2674    }
2675
2676    /// BP-8: how many rewinds are currently undoable.
2677    pub fn undoable_rewinds(&self) -> usize {
2678        self.rewind_undo.len()
2679    }
2680
2681    /// BP-8: restore the undo stack a previous process left in the journal,
2682    /// so `/rewind undo` works across a restart.
2683    pub fn restore_rewind_undo(&mut self, stack: Vec<Vec<ChatMessage>>) {
2684        self.rewind_undo = stack;
2685    }
2686
2687    /// BP-8 (catalog:154 "Queued-prompt persistence"): re-queue pending
2688    /// inputs recovered from the journal WITHOUT re-recording them — they
2689    /// are already in the log, and journaling them again would double them
2690    /// on the next restart.
2691    pub fn restore_queues(&mut self, steer: &[String], follow_up: &[String]) {
2692        for message in steer {
2693            self.steer_queue
2694                .lock()
2695                .unwrap_or_else(std::sync::PoisonError::into_inner)
2696                .queue_unchecked(message.clone());
2697        }
2698        for message in follow_up {
2699            self.follow_up_queue.push_back(message.clone());
2700        }
2701    }
2702
2703    /// BP-8 (catalog:156 "Todos/plan persisted per session"): the session's
2704    /// current `update_plan` checklist.
2705    pub fn plan(&self) -> Vec<crate::session_journal::PlanEntry> {
2706        self.ctx.plan_snapshot()
2707    }
2708
2709    /// BP-8: restore a plan read back from the store on resume. Marked as
2710    /// already-journaled, so a resume that changes nothing writes nothing.
2711    pub fn set_plan(&mut self, steps: Vec<crate::session_journal::PlanEntry>) {
2712        self.ctx.set_plan(steps.clone());
2713        self.journaled_plan = steps;
2714    }
2715
2716    /// BP-8 (catalog:154): record that `count` pending inputs left `queue`
2717    /// and became conversation. A no-op when nothing was taken, or when
2718    /// queue persistence is off.
2719    fn journal_queue_drain(&self, queue: crate::session_journal::QueueKind, count: usize) {
2720        if count == 0 || !self.config.session_queue_persist {
2721            return;
2722        }
2723        self.journal_op(crate::session_journal::JournalOp::Dequeue { queue, count });
2724    }
2725
2726    /// BP-8: journal the plan if `update_plan` changed it since the last
2727    /// time this ran. Called at every loop boundary — a plan that a crash
2728    /// would otherwise strand in the tool's memory is on disk within one
2729    /// iteration of being written.
2730    fn journal_plan_if_changed(&mut self) {
2731        if !self.config.todos_persist {
2732            return;
2733        }
2734        let current = self.ctx.plan_snapshot();
2735        if current == self.journaled_plan {
2736            return;
2737        }
2738        self.journaled_plan.clone_from(&current);
2739        self.journal_op(crate::session_journal::JournalOp::Plan { steps: current });
2740    }
2741
2742    /// Install (or replace) this agent's reduction policy (A5/A7/A10). Once
2743    /// set, every provider request is built from a *projected* view of
2744    /// `history[1..]` (`reduce::project_messages`) rather than `history`
2745    /// verbatim — `history` itself is never shrunk or mutated by this; only
2746    /// the request view does.
2747    pub fn set_reduction_policy(&mut self, policy: ReductionPolicy) {
2748        self.reduction_policy = Some(policy);
2749    }
2750
2751    /// This agent's reduction policy, if one is installed.
2752    pub fn reduction_policy(&self) -> Option<&ReductionPolicy> {
2753        self.reduction_policy.as_ref()
2754    }
2755
2756    /// Change the global tool-schema tier (TR-8/T5) mid-session. Takes effect
2757    /// starting with the NEXT request this agent builds. Under
2758    /// [`CachePlan::ImportedPrefix`], the first request built after a change
2759    /// is flagged as a cache-bust event and its cache-control annotation is
2760    /// skipped for that one request (see [`provider::tier_change_is_cache_bust`],
2761    /// consulted in `Self::build_request_messages`) — normal annotation
2762    /// resumes on the next request if the tier doesn't change again.
2763    pub fn set_schema_tier(&mut self, tier: crate::tools::SchemaTier) {
2764        self.config.tool_schema_tier = tier;
2765    }
2766
2767    /// Override the schema tier for a single tool (TR-8/T5) mid-session, same
2768    /// cache-bust interaction as [`Self::set_schema_tier`].
2769    pub fn set_tool_schema_tier(
2770        &mut self,
2771        name: impl Into<String>,
2772        tier: crate::tools::SchemaTier,
2773    ) {
2774        self.config
2775            .tool_overrides
2776            .entry(name.into())
2777            .or_default()
2778            .schema_tier = Some(tier);
2779    }
2780
2781    /// A deterministic fingerprint of the current tool-schema tier
2782    /// configuration (global knob + every per-tool override), used to detect
2783    /// a mid-session tier change (TR-8/T5, dev/05). Order-independent over
2784    /// `tool_overrides` (sorted by name before hashing) so insertion order
2785    /// never spuriously changes the signature.
2786    fn schema_tier_signature(&self) -> u64 {
2787        use std::hash::{Hash, Hasher};
2788        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2789        self.config.tool_schema_tier.hash(&mut hasher);
2790        let mut overrides: Vec<(&str, crate::tools::SchemaTier)> = self
2791            .config
2792            .tool_overrides
2793            .iter()
2794            .filter_map(|(name, o)| o.schema_tier.map(|t| (name.as_str(), t)))
2795            .collect();
2796        overrides.sort_by_key(|(name, _)| *name);
2797        for (name, tier) in overrides {
2798            name.hash(&mut hasher);
2799            tier.hash(&mut hasher);
2800        }
2801        hasher.finish()
2802    }
2803
2804    /// Install (or replace) this agent's TR-7 span summarizer — the
2805    /// injectable side-call `Self::build_request_messages` uses to turn an
2806    /// A10 `TurnsCleared` span into an LLM-written summary paragraph when
2807    /// `policy.summarize_cleared_turns` is on. Installing one alone changes
2808    /// nothing: [`ReductionPolicy::summarize_cleared_turns`] (off by
2809    /// default) is the actual gate, so tests/callers that want the
2810    /// deterministic stub can simply never call this.
2811    pub fn set_span_summarizer(
2812        &mut self,
2813        summarizer: impl reduce::summarize::SpanSummarizer + Send + Sync + 'static,
2814    ) {
2815        self.span_summarizer = Some(std::sync::Arc::new(summarizer));
2816    }
2817
2818    /// Install an already-shared summarizer — same seam as
2819    /// [`Self::set_span_summarizer`], for callers (and tests) that need to
2820    /// keep their own handle on it.
2821    pub fn set_span_summarizer_arc(
2822        &mut self,
2823        summarizer: std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>,
2824    ) {
2825        self.span_summarizer = Some(summarizer);
2826    }
2827
2828    /// Prepare TR-7 metadata with this agent's installed summarizer for a
2829    /// projection performed by an outer driver before session history/log
2830    /// are loaded (the CLI foreign-resume preflight). `None` preserves the
2831    /// deterministic fallback when the gate is off, no summarizer exists,
2832    /// the span is below the cost floor, or the side-call fails.
2833    pub fn prepare_cleared_turns_summary(
2834        &self,
2835        msgs: &[ChatMessage],
2836        policy: &ReductionPolicy,
2837        prior: &ReductionLog,
2838    ) -> Option<reduce::PreparedClearSummary> {
2839        let summarizer = self.span_summarizer.as_deref()?;
2840        reduce::prepare_cleared_turns_summary(msgs, policy, prior, summarizer)
2841    }
2842
2843    /// P5-4: install (or replace) this agent's [`crate::EventSink`] AFTER
2844    /// construction — `Config::event_sink` is otherwise only set at
2845    /// `Config`-build time (before `Agent::new`), which is too early for a
2846    /// `tui` embedder that only knows it's activating (and needs to
2847    /// replace whatever print-mode/REPL sink was already installed with
2848    /// one that feeds its own render loop instead of writing straight to
2849    /// stdout) once it already holds a live `Agent`. Mirrors [`Self::
2850    /// set_permissions_approval_handler`]'s "installing one alone changes
2851    /// nothing beyond what already consults `Config::event_sink`" pattern
2852    /// — this is a plain replacement, not a new activation gate.
2853    pub fn set_event_sink(&mut self, sink: crate::EventSink) {
2854        self.config.event_sink = Some(sink);
2855    }
2856
2857    /// P5-1: install (or replace) this agent's permissions-engine approval
2858    /// handler — see [`crate::permissions::PermissionsApprovalHandler`].
2859    /// This is the non-interactive decision seam a CLI/TUI/SDK embedder
2860    /// implements for the `Ask`-tier prompt; the TUI's actual interactive
2861    /// UI is a separate module (P5 row 4), not built here. Installing one
2862    /// alone changes nothing: [`Config::permissions_enabled`] (off by
2863    /// default) is the actual gate — with no handler installed, every
2864    /// `Ask`-tier decision denies (fail-closed, see that trait's doc
2865    /// comment).
2866    ///
2867    /// P5-10 (§2 module 12, `escalation = "ask"`): the SAME handler also
2868    /// backs a sandbox-unenforceable `ask` decision
2869    /// (`crate::sandbox::decide_fs`'s `approval` parameter) — one installed
2870    /// seam serves both `permissions.rules`' `Ask` tier and
2871    /// `permissions.sandbox`'s `escalation = "ask"`, rather than requiring
2872    /// an embedder to install two near-identical handlers. Kept in sync on
2873    /// `self.ctx` (not just `self.permissions_approval_handler`) because
2874    /// `BashTool::execute`/`PersistentShellTool::execute` only ever see
2875    /// `&ToolContext`, never `&Agent` — see `ToolContext::
2876    /// sandbox_approval_handler`'s doc comment.
2877    /// BP-10 (catalog row "Session approval caching"): this agent's
2878    /// approval cache — the door an embedder/TUI uses to inspect or REVOKE
2879    /// remembered grants (`ApprovalCache::clear` forgets every one, in
2880    /// memory and on disk, and the next matching call asks again). Also
2881    /// how a test proves a grant really did survive the process:
2882    /// `store_path()` names the file a second agent reads back.
2883    pub fn permissions_approval_cache(&self) -> &crate::permissions::ApprovalCache {
2884        &self.permissions_approval_cache
2885    }
2886
2887    pub fn set_permissions_approval_handler(
2888        &mut self,
2889        handler: impl crate::permissions::PermissionsApprovalHandler + 'static,
2890    ) {
2891        let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
2892            std::sync::Arc::new(handler);
2893        self.permissions_approval_handler = Some(handler.clone());
2894        self.ctx.sandbox_approval_handler =
2895            Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
2896        // BP-3 (§2 module 8): `exit_plan_mode` presents its plan on this
2897        // same door — one approval seam for the session, not a second one
2898        // the operator would have to answer separately.
2899        self.ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(handler));
2900    }
2901
2902    /// BP-3 (§2 module 6 `tools.question`): install the door `ask_user`
2903    /// asks the human through — the `elicitation/create` handler the design
2904    /// names as the module's protocol side. SDK-owned frontends pass the
2905    /// broker-backed handler (`crate::server::FrontendRequestBridge::
2906    /// elicitation_handler`), which is what makes the question a real
2907    /// frontend request the turn waits on. `None` (the default, nothing
2908    /// installed) leaves the tool deny-default: it reports that nobody can
2909    /// be asked instead of blocking.
2910    ///
2911    /// Installing one alone changes nothing about whether the tool EXISTS —
2912    /// `[capabilities.tools_question]` is that gate, applied by
2913    /// `crate::tools::ToolRegistry::from_config`.
2914    pub fn set_user_question_handler(
2915        &mut self,
2916        handler: std::sync::Arc<dyn crate::mcp::McpElicitationHandler>,
2917    ) {
2918        self.ctx.question_handler = Some(crate::tools::UserQuestionHandler(handler));
2919    }
2920
2921    /// BP-3 (§2 module 8): the shared plan-mode state, so a frontend (the
2922    /// REPL's `/plan`, a TUI toggle) can enter or leave the read-only
2923    /// research phase the same tools and permission gate see.
2924    pub fn plan_mode(&self) -> &std::sync::Arc<crate::tools::PlanModeState> {
2925        &self.ctx.plan_mode
2926    }
2927
2928    /// Install the compatibility approval seam used when the composable
2929    /// permissions engine is disabled. SDK-owned interactive frontends call
2930    /// this alongside [`Self::set_permissions_approval_handler`] so the same
2931    /// authenticated request channel works under either policy engine; the
2932    /// selected engine remains entirely a configuration decision.
2933    pub fn set_legacy_approval_handler(&mut self, handler: crate::config::ApprovalHandler) {
2934        self.config.approval_handler = Some(handler);
2935    }
2936
2937    /// P5-3 (§2 module 9 D5 "subagent transcripts… persisted + linked"):
2938    /// install a [`crate::store::SessionStore`] (+ this agent's own session
2939    /// name in it) so `spawn_subagent` persists each child's transcript
2940    /// (via [`crate::store::SessionStore::save_subagent_transcript`]) and
2941    /// lineage record (via
2942    /// [`crate::store::SessionStore::save_subagent_lineage`]) once the
2943    /// child finishes. Installing one alone changes nothing about whether
2944    /// spawning WORKS — [`Config::subagents_enabled`] is the actual gate;
2945    /// this only controls whether a completed spawn's transcript additionally
2946    /// lands on disk.
2947    pub fn set_subagent_store(
2948        &mut self,
2949        store: std::sync::Arc<crate::store::SessionStore>,
2950        session_name: impl Into<String>,
2951    ) {
2952        self.subagent_store = Some((store, session_name.into()));
2953    }
2954
2955    /// Seed the Claude runtime manifest reconstructed during resume.
2956    ///
2957    /// Installing state enables the matching Claude runtime tool schemas so
2958    /// a disk-reloaded continuation does not lose that vocabulary, but never
2959    /// starts a timer by itself. The supplied execution posture is preserved:
2960    /// an embedding scheduler may deliberately activate before installing it.
2961    pub fn set_claude_runtime_manifest(
2962        &mut self,
2963        manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
2964    ) {
2965        // A persisted manifest is itself the compatibility capability marker.
2966        // Reopening a Supercode session must not retain its timers while
2967        // silently dropping Claude's Cron*/ScheduleWakeup vocabulary.
2968        self.config.claude_runtime_tools_enabled = true;
2969        self.claude_runtime_manifest = Some(manifest);
2970    }
2971
2972    /// Reinstall project-scoped Claude named-agent definitions when a
2973    /// Supercode continuation carrying a Claude runtime manifest is reopened
2974    /// from disk. The manifest is the durable capability marker; definitions
2975    /// themselves remain authoritative in `<cwd>/.claude/agents/*.md`.
2976    pub fn restore_claude_project_agents(&mut self) -> Result<usize> {
2977        let definitions = crate::claude_compat::load_project_agents(&self.config.cwd)?;
2978        crate::claude_compat::enable_claude_subagent_compatibility(&mut self.config);
2979        for imported in &definitions {
2980            self.config.subagents_definitions.insert(
2981                imported.definition.name.clone(),
2982                imported.definition.clone(),
2983            );
2984        }
2985        Ok(definitions.len())
2986    }
2987
2988    /// Current imported Claude runtime state, including paused mutations made
2989    /// by `Cron*`/`ScheduleWakeup`, for persistence by the embedding loop.
2990    pub fn claude_runtime_manifest(
2991        &self,
2992    ) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
2993        self.claude_runtime_manifest.as_ref()
2994    }
2995
2996    /// Mutable access for an embedding scheduler driver to atomically claim
2997    /// due events and persist the resulting manifest. Merely borrowing this
2998    /// state does not start a timer; execution remains the driver's explicit
2999    /// responsibility.
3000    pub fn claude_runtime_manifest_mut(
3001        &mut self,
3002    ) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
3003        self.claude_runtime_manifest.as_mut()
3004    }
3005
3006    /// P5-4 (tui, closes the P5-3 §2.2 C6 deferred chain): install a
3007    /// factory this agent's `Self::run_spawn_subagent` calls (with the
3008    /// fresh child's own id and this agent's shared
3009    /// [`Self::pending_child_approvals`] queue) to build the
3010    /// `PermissionsApprovalHandler` a `background_prompts = "parent"`
3011    /// child gets, INSTEAD of the default
3012    /// [`crate::subagents::ParentQueueApprovalHandler`]. Installing one
3013    /// alone changes nothing about whether background spawning works —
3014    /// [`Config::subagents_background_prompts`] being
3015    /// [`crate::subagents::BackgroundPromptsPolicy::Parent`] is the actual
3016    /// gate that reaches this factory at all; a `Parent`-policy child
3017    /// spawned before this is installed (or on an agent that never installs
3018    /// it) still gets the immediate-deny default, unchanged.
3019    ///
3020    /// **Security note.** The factory only controls WHICH handler answers
3021    /// an `Ask`-tier request — it can never widen what gets asked in the
3022    /// first place: [`crate::permissions::approval::resolve_ask`] only
3023    /// calls a handler's `ask` when the rule engine has already resolved
3024    /// the call to `Ask` (`Deny` short-circuits before any handler is
3025    /// consulted; `Allow` never needs one), so a parent's "allow" answer
3026    /// here can only grant what the policy already routed to a prompt —
3027    /// never override a `Deny` the engine already decided.
3028    pub fn set_child_approval_handler_factory(
3029        &mut self,
3030        factory: impl Fn(
3031                String,
3032                std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
3033            ) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
3034            + Send
3035            + Sync
3036            + 'static,
3037    ) {
3038        self.child_approval_handler_factory = Some(std::sync::Arc::new(factory));
3039    }
3040
3041    /// P5-3 (§2.2 C6 "parent-surfaced queue"): every approval request a
3042    /// `background_prompts = "parent"` child has raised so far, oldest
3043    /// first — a read-only audit view, not a mutable queue the caller
3044    /// answers. Under P5-3's own default handler (no P5-4 TUI factory
3045    /// installed) every entry here WAS already resolved `Deny` (a
3046    /// background call can't wait for an answer with no handler
3047    /// installed) — but once a `crate::tui::TuiChildApprovalHandler`
3048    /// factory is installed (P5-4,
3049    /// [`Self::set_child_approval_handler_factory`]), the underlying call
3050    /// genuinely blocks and may resolve `Allow`/`AllowForSession`; this
3051    /// method still records the SAME entry for the audit trail either
3052    /// way, so "queued here" no longer implies "was denied" in general —
3053    /// see [`crate::subagents::QueuedApproval`]'s doc comment.
3054    pub fn pending_child_approvals(&self) -> Vec<crate::subagents::QueuedApproval> {
3055        self.pending_child_approvals
3056            .lock()
3057            .map(|q| q.clone())
3058            .unwrap_or_default()
3059    }
3060
3061    /// P4b: install (or replace) this agent's auto-title side-call — see
3062    /// [`crate::session_title::SessionTitler`]. Installing one alone changes
3063    /// nothing: [`Config::auto_title`] (off by default) is the actual gate a
3064    /// caller should consult before calling [`Self::auto_title`].
3065    pub fn set_session_titler(
3066        &mut self,
3067        titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
3068    ) {
3069        self.session_titler = Some(std::sync::Arc::new(titler));
3070    }
3071
3072    /// P4b: produce a title for this agent's current conversation via the
3073    /// installed [`Self::set_session_titler`] side-call. Returns `None` (never
3074    /// panics, never blocks longer than the titler itself does) if no
3075    /// titler is installed, or the side-call itself declined (see
3076    /// [`crate::session_title::auto_title`]). Does NOT consult
3077    /// [`Config::auto_title`] itself — that gate is the caller's
3078    /// responsibility, matching `Self::span_summarizer`'s precedent of
3079    /// keeping the mechanism and the policy gate separate.
3080    pub fn auto_title(&self) -> Option<String> {
3081        let titler = self.session_titler.as_deref()?;
3082        crate::session_title::auto_title(&self.history, titler)
3083    }
3084
3085    /// P4b (§1.6, catalog §4a "persisted per-turn usage records"): every
3086    /// [`crate::usage_log::UsageRecord`] this agent has accumulated so far.
3087    pub fn usage_records(&self) -> &[crate::usage_log::UsageRecord] {
3088        &self.usage_log
3089    }
3090
3091    /// P4b: persist this agent's accumulated usage log to `store` under
3092    /// `name` — a thin wrapper over [`crate::store::SessionStore::save_usage_log`]
3093    /// so callers don't need to import both types.
3094    pub fn save_usage_log(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3095        store.save_usage_log(name, &self.usage_log)
3096    }
3097
3098    /// BP-7 (catalog §4a "Turn/step bracketing records"): every
3099    /// [`crate::turn_record::TurnRecord`] this agent has accumulated —
3100    /// the context/usage/finish brackets of each model round-trip plus the
3101    /// retry, abort, effort and goal markers between them.
3102    pub fn turn_records(&self) -> &[crate::turn_record::TurnRecord] {
3103        &self.turn_records
3104    }
3105
3106    /// BP-7: persist the marker log to `store` under `name`
3107    /// (`<name>.events.jsonl`), the same thin-wrapper shape
3108    /// [`Self::save_usage_log`] has.
3109    pub fn save_turn_records(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3110        store.save_turn_records(name, &self.turn_records)
3111    }
3112
3113    /// BP-7 (catalog §4a "Per-turn cost/usage accounting"): dollars this
3114    /// agent has spent so far. `0.0` when the model is unpriceable — read
3115    /// [`Self::model_priced`] to tell "free" from "unknown".
3116    pub fn total_cost_usd(&self) -> f64 {
3117        self.total_cost_usd
3118    }
3119
3120    /// BP-7: whether this build can price this agent's model, i.e. whether
3121    /// [`Self::total_cost_usd`] is a real figure rather than a floor.
3122    pub fn model_priced(&self) -> bool {
3123        self.model_price.is_some()
3124    }
3125
3126    /// BP-7 (catalog §4a "Turn/budget caps"): tool calls this agent has
3127    /// executed so far — the counter [`Config::max_steps`] bounds.
3128    pub fn total_steps(&self) -> usize {
3129        self.total_steps
3130    }
3131
3132    /// BP-7 (catalog §4a "Interrupt/abort with state preserved"): record
3133    /// that the in-flight turn was interrupted.
3134    ///
3135    /// Called by whoever owns the cancellation (the CLI's Ctrl-C race), NOT
3136    /// by the loop itself: a cancelled `send` future is dropped mid-await,
3137    /// so the loop never runs another line. The partial work already
3138    /// appended to the transcript stands; this marker is what makes the
3139    /// interruption a persisted FACT — the residue the ledger row named —
3140    /// rather than something a reader has to infer from a dangling tool
3141    /// call on reload. Emits [`AgentEvent::TurnAborted`] as the live
3142    /// counterpart.
3143    pub fn note_abort(&mut self, source: &str) {
3144        let messages = self.history.len();
3145        self.emit(AgentEvent::TurnAborted {
3146            source: source.to_string(),
3147        });
3148        self.push_turn_marker(crate::turn_record::TurnMarker::Aborted {
3149            source: source.to_string(),
3150            messages,
3151        });
3152    }
3153
3154    // ---- BP-7: goals (catalog §4a "Goals — persistent objective across
3155    // turns"; §2 module 7 `todos`, §3.1 `capabilities.todos.goals`) ----
3156
3157    /// Set (or revise) this session's standing objective.
3158    ///
3159    /// Returns `false`, changing nothing, when `capabilities.todos.goals`
3160    /// is off — the module gate, not a silent success. A goal restates
3161    /// itself at the tail of every request until [`Self::clear_goal`], and
3162    /// each change appends a `goal` marker to the turn-record log.
3163    pub fn set_goal(&mut self, objective: impl Into<String>) -> bool {
3164        if !self.config.goals_enabled {
3165            return false;
3166        }
3167        let objective = objective.into();
3168        let now = now_ms();
3169        match &mut self.goal {
3170            Some(goal) => goal.revise(objective.clone(), now),
3171            slot @ None => *slot = Some(crate::goals::GoalRecord::new(objective.clone(), now)),
3172        }
3173        self.push_turn_marker(crate::turn_record::TurnMarker::Goal { objective });
3174        true
3175    }
3176
3177    /// This session's standing objective, if one is set.
3178    pub fn goal(&self) -> Option<&crate::goals::GoalRecord> {
3179        self.goal.as_ref()
3180    }
3181
3182    /// Drop the standing objective. `true` when there was one to drop.
3183    pub fn clear_goal(&mut self) -> bool {
3184        if self.goal.take().is_none() {
3185            return false;
3186        }
3187        self.push_turn_marker(crate::turn_record::TurnMarker::Goal {
3188            objective: String::new(),
3189        });
3190        true
3191    }
3192
3193    /// BP-7: persist (or, when cleared, remove) the standing objective
3194    /// beside the session — same thin-wrapper shape as
3195    /// [`Self::save_usage_log`].
3196    pub fn save_goal(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3197        match &self.goal {
3198            Some(goal) => store.save_goal(name, goal),
3199            None => store.clear_goal(name),
3200        }
3201    }
3202
3203    /// BP-7: adopt a goal loaded from the store (a resumed session picks up
3204    /// exactly where it left off). Bypasses the module gate on purpose: a
3205    /// goal already persisted is data to restore, not a new capability
3206    /// being turned on, and dropping it silently would lose session state.
3207    pub fn restore_goal(&mut self, goal: Option<crate::goals::GoalRecord>) {
3208        self.goal = goal;
3209    }
3210
3211    // ---- BP-7: extended-thinking control (catalog §4a "Extended thinking
3212    // control": "Reasoning on/off/levels mid-session") ----
3213
3214    /// The reasoning-effort level in force for the NEXT request, or `None`
3215    /// when extended thinking is off.
3216    pub fn effort(&self) -> Option<&str> {
3217        self.config.effort.as_deref()
3218    }
3219
3220    /// Change the reasoning-effort level mid-session.
3221    ///
3222    /// `Some(level)` sets the level; `None` turns extended thinking OFF —
3223    /// the on/off toggle the ledger row named as distinct from the level.
3224    /// `run_loop` reads `self.config.effort` fresh when it builds each
3225    /// `ChatRequest`, so this takes effect on the very next request with no
3226    /// other copy to update (the same contract [`Self::set_model`] has).
3227    /// The change is appended to the turn-record log as an `effort` marker,
3228    /// the extended-thinking analog of the `model_change` log.
3229    ///
3230    /// Returns the PREVIOUS setting.
3231    pub fn set_effort(&mut self, effort: Option<String>) -> Option<String> {
3232        let previous = self.config.effort.clone();
3233        if previous == effort {
3234            return previous;
3235        }
3236        self.config.effort = effort.clone();
3237        self.push_turn_marker(crate::turn_record::TurnMarker::Effort {
3238            from: previous.clone(),
3239            to: effort,
3240        });
3241        previous
3242    }
3243
3244    // ---- BP-7: review mode (catalog §4a "Review mode — dedicated
3245    // code-review flow"; §3.1 `[core.prompts]`) ----
3246
3247    /// The purpose-built review turn's prompt: the `code-review` template
3248    /// from [`Config::prompts`] with `{args}` replaced by `args`.
3249    ///
3250    /// `None` when the resolved config carries no `code-review` template —
3251    /// the preset decides whether this harness has a review mode, and the
3252    /// template IS the report format (both parity presets pin one).
3253    pub fn review_prompt(&self, args: &str) -> Option<String> {
3254        self.config
3255            .prompts
3256            .get(REVIEW_PROMPT_NAME)
3257            .map(|template| template.replace("{args}", args.trim()))
3258    }
3259
3260    /// Run the review turn: an ordinary [`Self::send`] of
3261    /// [`Self::review_prompt`], so the review's request, tools, transcript
3262    /// and records are the session's own — a purpose-built TURN, not a
3263    /// second agent.
3264    pub async fn review(&mut self, args: &str) -> Result<String> {
3265        let prompt = self.review_prompt(args).ok_or_else(|| {
3266            Error::Other(format!(
3267                "no `{REVIEW_PROMPT_NAME}` prompt template is configured for this harness"
3268            ))
3269        })?;
3270        self.send(prompt).await
3271    }
3272
3273    // ---- BP-7: side/ephemeral Q&A (catalog §4a "Side/ephemeral Q&A":
3274    // "Tool-less question over full context, never enters history") ----
3275
3276    /// Answer `question` over this session's FULL current context without
3277    /// recording anything.
3278    ///
3279    /// Three properties, all load-bearing and all asserted by this build's
3280    /// tests: the request carries the whole conversation as the next turn
3281    /// would see it; it advertises NO tools, so the model can only answer;
3282    /// and neither `history`, the sidecar recorder, the usage log nor the
3283    /// turn-record log is touched — `&self`, not `&mut self`, is the type
3284    /// system saying so. cc's `/btw` and cx's `/side`.
3285    pub async fn side_question(&self, question: &str) -> Result<String> {
3286        let mut messages = self.history.clone();
3287        if let Some(goal) = &self.goal {
3288            messages.push(ChatMessage::system(goal.reminder()));
3289        }
3290        messages.push(ChatMessage::user(format!(
3291            "{SIDE_QUESTION_PREAMBLE}
3292
3293{question}"
3294        )));
3295        let mut req = ChatRequest {
3296            model: self.config.model.clone(),
3297            messages,
3298            tools: Vec::new(),
3299            temperature: self.config.temperature,
3300            max_tokens: self.config.max_tokens,
3301            effort: self.config.effort.clone(),
3302            response_format: None,
3303            service_tier: None,
3304            thinking_budget: None,
3305            extra_body: self.config.extra_body.clone(),
3306        };
3307        // BP-13: a side question is still a request to THIS model, so it
3308        // carries the same routing decisions the loop's own requests do.
3309        self.apply_routing(&mut req);
3310        let (assistant, _usage) = self.provider.complete(&req, &|_: &str| {}).await?;
3311        Ok(assistant.content.unwrap_or_default())
3312    }
3313
3314    /// BP-7: append one marker against the NEXT round-trip's index — the
3315    /// right frame for a marker written between turns (a goal change, an
3316    /// effort change, an abort).
3317    fn push_turn_marker(&mut self, marker: crate::turn_record::TurnMarker) {
3318        self.push_turn_marker_at(self.turn_index, marker);
3319    }
3320
3321    /// BP-7: append one marker against an explicit round-trip index — used
3322    /// inside `Self::run_loop`, where markers are written on both sides of
3323    /// the `turn_index` advance and must all carry the round-trip they
3324    /// describe.
3325    fn push_turn_marker_at(&mut self, turn: usize, marker: crate::turn_record::TurnMarker) {
3326        self.turn_records.push(crate::turn_record::TurnRecord::new(
3327            turn,
3328            &self.config.model,
3329            now_ms(),
3330            marker,
3331        ));
3332    }
3333
3334    /// P4b (§1.7, pi§3 semantics): queue a mid-turn steering message —
3335    /// delivered "after current tool calls" (pi's phrasing): at the top of
3336    /// `Self::run_loop`'s NEXT iteration, before the next model request is
3337    /// built, regardless of whether this turn is still mid-flight with
3338    /// pending tool calls. Drained per [`Config::steering_mode`].
3339    pub fn queue_steer(&self, message: impl Into<String>) {
3340        let message = message.into();
3341        // BP-8 (catalog:154 "Queued-prompt persistence"): the input is
3342        // recorded BEFORE it is queued, so the window in which a crash
3343        // could lose it is zero. A no-op when `core.session.queue_persist`
3344        // is off (cx-parity: stock Codex has no queue-operation records).
3345        if self.config.session_queue_persist {
3346            self.journal_op(crate::session_journal::JournalOp::Enqueue {
3347                queue: crate::session_journal::QueueKind::Steer,
3348                text: message.clone(),
3349            });
3350        }
3351        self.steer_queue
3352            .lock()
3353            .unwrap_or_else(std::sync::PoisonError::into_inner)
3354            .queue_unchecked(message);
3355    }
3356
3357    /// Crate-internal shared steering handle used by the canonical SDK
3358    /// runtime. It remains writable while an active turn holds `&mut Agent`,
3359    /// allowing local and remote frontends to steer without owning the loop.
3360    pub(crate) fn steer_queue_handle(&self) -> std::sync::Arc<std::sync::Mutex<SteerInbox>> {
3361        self.steer_queue.clone()
3362    }
3363
3364    /// P4b: queue a follow-up message — delivered "at idle" (pi's phrasing):
3365    /// only once `Self::run_loop` would otherwise return a final answer
3366    /// (no more tool calls pending). Drained per [`Config::follow_up_mode`].
3367    pub fn queue_follow_up(&mut self, message: impl Into<String>) {
3368        let message = message.into();
3369        // BP-8 (catalog:154): same record-then-queue order as
3370        // [`Self::queue_steer`].
3371        if self.config.session_queue_persist {
3372            self.journal_op(crate::session_journal::JournalOp::Enqueue {
3373                queue: crate::session_journal::QueueKind::FollowUp,
3374                text: message.clone(),
3375            });
3376        }
3377        self.follow_up_queue.push_back(message);
3378    }
3379
3380    /// P4b: how many steering messages are currently queued (mid-turn +
3381    /// follow-up combined) — mostly for tests/diagnostics.
3382    pub fn queued_steer_count(&self) -> usize {
3383        self.steer_queue
3384            .lock()
3385            .unwrap_or_else(std::sync::PoisonError::into_inner)
3386            .len()
3387            + self.follow_up_queue.len()
3388    }
3389
3390    /// The accumulating reduction log (A5) — every reduction applied to any
3391    /// projected request view so far. Combined with a full-fidelity sidecar
3392    /// Session, this is enough to `reduce::invert` any projected view back to
3393    /// the exact original.
3394    pub fn reduction_log(&self) -> &ReductionLog {
3395        &self.reduction_log
3396    }
3397
3398    /// PARITY-18 D4 — arm the per-send context guard: `Self::run_loop`
3399    /// will refuse (via [`Error::ContextLimitExceeded`]) to build and issue
3400    /// ANY request — the first or any later turn — whose
3401    /// [`crate::tokens::context_guard`] verdict is "does not fit" against
3402    /// `limit`. Call this once the target model's context-window size is
3403    /// known (`resume --reduced`'s preflight already computes it). Leaving
3404    /// this unset (the default) is a no-op: no guard runs, exactly today's
3405    /// pre-PARITY-18 behavior.
3406    pub fn set_context_limit(&mut self, limit: u64) {
3407        self.context_limit = Some(limit);
3408    }
3409
3410    /// This agent's armed context limit, if [`Self::set_context_limit`] has
3411    /// been called.
3412    pub fn context_limit(&self) -> Option<u64> {
3413        self.context_limit
3414    }
3415
3416    /// The model identifier this agent sends on its next request
3417    /// ([`Config::model`], as of construction/resume or the last
3418    /// [`Self::set_model`] call).
3419    pub fn model(&self) -> &str {
3420        &self.config.model
3421    }
3422
3423    /// UX-30 dev/02 — switch the model this agent sends, starting with the
3424    /// NEXT request it builds (and every one after, until changed again).
3425    /// `Self::run_loop` reads `self.config.model` fresh on every request
3426    /// (see its `ChatRequest` construction), so this alone is enough —
3427    /// there is no cached/baked-in copy anywhere else to also update.
3428    /// Takes effect immediately; safe to call only between turns (the
3429    /// REPL's `/model` picker runs at the prompt, never mid-turn). Touches
3430    /// nothing else: history, the sidecar, and reduction state are exactly
3431    /// as untouched as [`Self::set_schema_tier`] leaves them for a
3432    /// mid-session tier change.
3433    ///
3434    /// P4c-review note: this is the LOW-LEVEL primitive — it swaps
3435    /// [`Config::model`] and nothing else. It does NOT run dep 8's
3436    /// reasoning-artifact filter
3437    /// ([`reduce::rehydrate::filter_reasoning_artifacts`]) and does NOT
3438    /// create a [`crate::model_change::ModelChangeRecord`], so calling it
3439    /// directly for a mid-session handoff between two DIFFERENT models
3440    /// leaves model-A's reasoning artifacts in `history` for model-B to
3441    /// inherit. [`Self::switch_model`] is the safe superset — gated by
3442    /// [`Config::model_switch_allow_switch`], it filters and records the
3443    /// switch before delegating to this method — and is what callers
3444    /// performing a governed mid-session model switch should use instead.
3445    pub fn set_model(&mut self, model: impl Into<String>) {
3446        self.config.model = model.into();
3447        // BP-5 (catalog D2 "Per-model-family base-prompt selection"): the
3448        // family's base prompt follows the model. Codex re-selects
3449        // `base_instructions` when the model changes; leaving model-A's
3450        // base prompt in front of model-B is exactly the mismatch the row
3451        // exists to prevent. Same locate-and-replace mechanism
3452        // `refresh_env_context` uses, and a no-op whenever the selection
3453        // did not actually change (always, for a config with no family
3454        // table).
3455        self.refresh_base_prompt();
3456        // BP-7: the price follows the model, or the per-turn cost figure
3457        // would keep billing the OLD model's rates after a switch.
3458        self.model_price = crate::pricing::resolve(
3459            &self.config.model,
3460            self.config.price_input_per_mtok,
3461            self.config.price_output_per_mtok,
3462        );
3463    }
3464
3465    /// P4c (§1.10/§3.1 `core.model_switch.allow_switch`, D9 row, dep 8,
3466    /// design's "core NEW-significant" item): the mid-session model
3467    /// switch — a superset of [`Self::set_model`] gated by
3468    /// [`Config::model_switch_allow_switch`].
3469    ///
3470    /// **`allow_switch = false` (the default): EXACTLY [`Self::set_model`]**
3471    /// — same single field write, nothing else touched, no
3472    /// [`crate::model_change::ModelChangeRecord`] created. Byte-identical to
3473    /// calling `set_model` directly.
3474    ///
3475    /// **`allow_switch = true`:** additionally, before the swap takes
3476    /// effect, runs [`reduce::rehydrate::filter_reasoning_artifacts`] over
3477    /// [`Self::history`] — model-A's reasoning/thinking artifacts (any
3478    /// [`crate::message::ChatMessage::metadata`] key in
3479    /// [`reduce::rehydrate::REASONING_METADATA_KEYS`], any `content_parts`
3480    /// block whose `"type"` is in
3481    /// [`reduce::rehydrate::REASONING_CONTENT_PART_TYPES`]) are stripped
3482    /// BEFORE model-B ever builds a request from this history — then
3483    /// appends a typed, translatable [`crate::model_change::ModelChangeRecord`]
3484    /// to [`Self::model_change_records`] (persist it via
3485    /// [`Self::save_model_change_log`]). A switch TO the current model
3486    /// (`model == Self::model()`) is treated as a no-op — still exactly
3487    /// `set_model`'s mechanics, no record for a switch that didn't actually
3488    /// change anything (and nothing to filter FOR, since there was no
3489    /// handoff).
3490    pub fn switch_model(&mut self, model: impl Into<String>) {
3491        let to = model.into();
3492        if !self.config.model_switch_allow_switch || self.config.model == to {
3493            self.set_model(to);
3494            return;
3495        }
3496        let from = self.config.model.clone();
3497        self.record_model_change(&from, &to, None);
3498    }
3499
3500    /// BP-13 — the ONE place a mid-session model change is performed and
3501    /// recorded, shared by [`Self::switch_model`] (a user asked) and the
3502    /// run loop's fallback pass (a provider failed).
3503    ///
3504    /// It does four things, in this order, and nothing else: strips model-A
3505    /// reasoning artifacts out of the live history (dep 8 — model B must
3506    /// never inherit them), moves [`Config::model`], appends the typed
3507    /// [`crate::model_change::ModelChangeRecord`], and writes that same
3508    /// record into the append-only session journal (BP-8) — which is where
3509    /// every persisted routing record lives; there is no second file. The
3510    /// change is also EMITTED, so a surface that renders events shows the
3511    /// switch instead of silently answering as a different model.
3512    pub fn record_model_change(&mut self, from: &str, to: &str, reason: Option<&str>) {
3513        if from == to {
3514            return;
3515        }
3516        let touched = reduce::rehydrate::filter_reasoning_artifacts(&mut self.history);
3517        self.set_model(to.to_string());
3518        let record = crate::model_change::ModelChangeRecord::new(
3519            self.turn_index,
3520            from,
3521            to,
3522            true,
3523            touched,
3524            now_ms(),
3525        )
3526        .with_reason(reason.map(str::to_string));
3527        self.journal_model_change(&record);
3528        self.model_change_log.push(record);
3529        self.emit(AgentEvent::ModelChanged {
3530            from: from.to_string(),
3531            to: to.to_string(),
3532            reason: reason.map(str::to_string),
3533        });
3534        // A switch also re-injects the switch NOTICE when the config asks
3535        // for one (Codex's own mid-session behavior: the conversation is
3536        // told the model changed, so the new model reads the handoff rather
3537        // than inferring it from a style break).
3538        if self.config.model_switch_notice {
3539            let notice = ChatMessage::user(format!(
3540                "[model changed: {from} -> {to}{}]",
3541                match reason {
3542                    Some(r) => format!(" ({r})"),
3543                    None => String::new(),
3544                }
3545            ));
3546            let _ = self.record(&notice);
3547            self.history.push(notice);
3548        }
3549    }
3550
3551    /// BP-13 (catalog D9 "Fast mode / service tiers"): set or clear the
3552    /// session-level service-tier override. `Some(tier)` WINS over the
3553    /// `[capabilities.model_catalog] service_tier` rule for every
3554    /// subsequent request (it is the live toggle the user just pulled);
3555    /// `None` puts the configured rule back in charge. Takes effect on the
3556    /// next request the loop builds, like [`Self::set_model`].
3557    pub fn set_service_tier(&mut self, tier: Option<String>) {
3558        self.config.service_tier = tier;
3559    }
3560
3561    /// BP-13 — apply the routing table to a request that already names its
3562    /// model: effort LEVEL (per-model override of `[core] effort`, clamped
3563    /// by whichever effort cap applies), thinking-token BUDGET, and service
3564    /// TIER (the live `/fast` override winning over the configured rule).
3565    /// Called for every request the loop builds AND again for every
3566    /// fallback hop, so a hop to a different model gets that model's
3567    /// routing rather than the previous model's.
3568    fn apply_routing(&self, req: &mut ChatRequest) {
3569        let routing = &self.config.model_routing;
3570        let rules = routing.rules_for(&req.model);
3571        // Plan mode's own effort tier, when the mode is live, is the
3572        // session level for this request — Codex's `/plan` is effort
3573        // steering (cx§6), so planning need not think at the executing
3574        // level. It is still clamped by whatever effort cap applies,
3575        // because `effective_effort` does the clamping, not this line.
3576        let session_effort = match (
3577            self.ctx.plan_mode.is_active(),
3578            self.config.plan_mode_effort.as_deref(),
3579        ) {
3580            (true, Some(effort)) => Some(effort),
3581            _ => self.config.effort.as_deref(),
3582        };
3583        req.effort = routing.effective_effort(&req.model, session_effort);
3584        req.thinking_budget = rules.thinking_budget;
3585        req.service_tier = self.config.service_tier.clone().or(rules.service_tier);
3586    }
3587
3588    /// BP-13 — send `req`, walking [`Config::model_fallback`] when the
3589    /// failure is one another model could plausibly answer.
3590    ///
3591    /// Returns the final outcome plus the hops actually taken, so the
3592    /// caller (which owns `&mut self`) can record each one. Each hop
3593    /// re-applies routing for the new model and strips model-A reasoning
3594    /// artifacts from the request's own message copy before model B sees
3595    /// them — the same dep-8 guarantee [`Self::record_model_change`] gives
3596    /// the live history.
3597    async fn complete_with_fallback(
3598        &self,
3599        req: &mut ChatRequest,
3600        on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
3601    ) -> (Result<(ChatMessage, provider::Usage)>, Vec<FallbackHop>) {
3602        let mut hops = Vec::new();
3603        let mut result = self.provider.complete(req, on_delta).await;
3604        for next in &self.config.model_fallback {
3605            let Err(error) = &result else {
3606                break;
3607            };
3608            if !is_failover_worthy(error) {
3609                break;
3610            }
3611            if next.is_empty() || next == &req.model {
3612                continue;
3613            }
3614            let reason = error.to_string();
3615            let from = std::mem::replace(&mut req.model, next.clone());
3616            reduce::rehydrate::filter_reasoning_artifacts(&mut req.messages);
3617            self.apply_routing(req);
3618            hops.push(FallbackHop {
3619                from,
3620                to: next.clone(),
3621                reason,
3622            });
3623            result = self.provider.complete(req, on_delta).await;
3624        }
3625        (result, hops)
3626    }
3627
3628    /// P4c: every [`crate::model_change::ModelChangeRecord`] this agent has
3629    /// accumulated so far (via [`Self::switch_model`] with `allow_switch`
3630    /// on). Empty when the knob is off or no switch has happened yet.
3631    pub fn model_change_records(&self) -> &[crate::model_change::ModelChangeRecord] {
3632        &self.model_change_log
3633    }
3634
3635    /// P4c: persist this agent's accumulated model-change log to `store`
3636    /// under `name` — the [`crate::model_change::ModelChangeRecord`] analog
3637    /// of [`Self::save_usage_log`].
3638    pub fn save_model_change_log(
3639        &self,
3640        store: &crate::store::SessionStore,
3641        name: &str,
3642    ) -> Result<()> {
3643        store.save_model_change_log(name, &self.model_change_log)
3644    }
3645
3646    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331): this
3647    /// agent's captured git provenance, if [`Config::session_git_metadata`]
3648    /// was on at construction and the best-effort probe found a repo.
3649    pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
3650        self.git_metadata.as_ref()
3651    }
3652
3653    /// P4e: persist this agent's captured git metadata to `store` under
3654    /// `name` — a thin wrapper over
3655    /// [`crate::store::SessionStore::save_git_metadata`], the
3656    /// [`crate::git_metadata::GitMetadataRecord`] analog of
3657    /// [`Self::save_usage_log`]. A no-op (`Ok(())`, nothing written) when
3658    /// [`Self::git_metadata`] is `None`.
3659    pub fn save_git_metadata(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
3660        match &self.git_metadata {
3661            Some(record) => store.save_git_metadata(name, record),
3662            None => Ok(()),
3663        }
3664    }
3665
3666    /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.persist`
3667    /// had a `Config` field and CLI plumbing at `ConfigProfile` → `Config` but
3668    /// no consumer at all): whether a CLI caller's session-store save sites
3669    /// (`persist_session`, `persist_full_view`) should actually write to
3670    /// disk. `true` (the default) is byte-identical to pre-fix behavior —
3671    /// every session persists. `false` makes a session ephemeral: it runs
3672    /// exactly as before, but no `<name>.jsonl`/sidecar family is ever
3673    /// written for it. A plain getter, same posture as [`Self::model`] —
3674    /// this crate itself never reads or enforces it; the CLI's save sites do.
3675    pub fn session_persist(&self) -> bool {
3676        self.config.session_persist
3677    }
3678
3679    /// P4e DEFECT-FIX (independent Fable-5 review of P4e: `core.session.name`
3680    /// had a `Config` field and CLI plumbing but no consumer): the
3681    /// caller-configured session name, if `[core.session] name` was set.
3682    /// `None` (the default) leaves session naming exactly as before —
3683    /// `mint_session_name`'s auto-generated `<tag>-<adjective>-<noun>` shape.
3684    /// A plain getter, same posture as [`Self::session_persist`].
3685    pub fn session_name(&self) -> Option<&str> {
3686        self.config.session_name.as_deref()
3687    }
3688
3689    /// PARITY-18 D3 — whether this agent has actually issued at least one
3690    /// live request to its [`Provider`] so far (set the instant
3691    /// `Self::run_loop` reaches its real send site, regardless of whether
3692    /// that call then succeeds or fails). Callers should report
3693    /// "request sent" from THIS, never from having merely passed the
3694    /// context guard or having called [`Self::send`] — either of those can
3695    /// happen with zero requests actually issued (a guard refusal, an
3696    /// interactive session quit before any turn completes).
3697    pub fn request_issued(&self) -> bool {
3698        self.requests_issued
3699    }
3700
3701    /// P5-2 (§2.2 C2): whether this agent currently considers its
3702    /// [`CachePlan::ImportedPrefix`] cache entry warm — mirrors
3703    /// [`Self::request_issued`]'s read-only-observability precedent, so a
3704    /// caller (or a test) can confirm [`Self::register_tool`]'s C2
3705    /// invalidation actually took effect without reaching into private
3706    /// state.
3707    pub fn cache_established(&self) -> bool {
3708        self.cache_established
3709    }
3710
3711    /// B7: length of the imported-prefix protected by [`CachePlan::ImportedPrefix`]
3712    /// (this agent's own system message plus every message of a
3713    /// previously-imported session), set by [`Self::load_session`]. `None`
3714    /// until a session has been loaded.
3715    pub fn imported_prefix_len(&self) -> Option<usize> {
3716        self.imported_prefix_len
3717    }
3718
3719    /// Replace this agent's accumulating reduction log (C4: `/expand`/`/reduce`
3720    /// mutate the log directly via `reduce::invert_one`/`reduce::project_messages`
3721    /// and must feed the result back here so the *next* request build or
3722    /// persist sees the updated state instead of silently recomputing from an
3723    /// empty log). Also lets a caller (`resume_cmd`, C1) seed the log with the
3724    /// initial projection it already computed for the entry banner, so
3725    /// `reduction_log()` reflects reality even before this agent's first
3726    /// `send()` (which is otherwise the only place `build_request_messages`
3727    /// populates it).
3728    pub fn set_reduction_log(&mut self, log: ReductionLog) {
3729        self.reduction_log = log;
3730    }
3731
3732    /// Replace the conversation with a loaded session, keeping this agent's own
3733    /// system prompt at the front. The session's own system/developer turns are
3734    /// preserved after it for context.
3735    pub fn load_session(&mut self, session: Session) {
3736        let system = self.history.first().cloned();
3737        self.history.clear();
3738        if let Some(sys) = system {
3739            self.history.push(sys);
3740        }
3741        self.history.extend(session.messages);
3742        // B7: the whole of `history` at this point — this agent's own system
3743        // message plus every imported message — is the stable prefix a
3744        // resumed session resends byte-identically every turn.
3745        self.imported_prefix_len = Some(self.history.len());
3746        // UX-26 (B7-warn): a freshly loaded prefix has no established cache
3747        // entry of THIS agent's own making yet (even if this agent was
3748        // resumed once before — that earlier prefix is gone). Seed the
3749        // activity clock from the loaded session's own last message
3750        // timestamp (walking backward past any trailing message that
3751        // carries none), so a session that's been sitting idle since
3752        // Claude Code/Codex/a prior supercode run last touched it is
3753        // correctly treated as already-cold on its very first turn here —
3754        // `None` (no timestamp anywhere in the loaded messages) leaves the
3755        // TTL check disarmed rather than guessing.
3756        self.cache_established = false;
3757        self.last_cache_activity_ms = self
3758            .history
3759            .iter()
3760            .rev()
3761            .find_map(|m| m.metadata.get("timestamp"))
3762            .and_then(|ts| crate::sidecar::rfc3339_to_ms(ts));
3763    }
3764
3765    /// Append `msg` to the sidecar recorder (A3), if one is installed — a
3766    /// no-op, at zero cost, when `recorder` is `None` (today's behavior).
3767    fn record(&mut self, msg: &ChatMessage) -> Result<()> {
3768        if let Some(recorder) = self.recorder.as_mut() {
3769            recorder.append(msg)?;
3770        }
3771        // BP-8 (catalog:150): the append-only half — written and FLUSHED
3772        // here, at the moment the message exists, not at the end of the
3773        // turn. A journal failure is logged, never fatal: durability
3774        // bookkeeping must not be able to fail a turn.
3775        if let Some(journal) = &self.journal {
3776            let mut guard = journal
3777                .lock()
3778                .unwrap_or_else(std::sync::PoisonError::into_inner);
3779            if let Err(error) = guard.append_message(msg) {
3780                tracing::warn!("failed to journal a message: {error}");
3781            }
3782        }
3783        // BP-8 (catalog:151): the same message becomes a tree node, so the
3784        // tree and the linear history never disagree about what was said.
3785        if let Some(tree) = self.session_tree.as_mut() {
3786            tree.append_message(msg.clone(), now_ms());
3787        }
3788        Ok(())
3789    }
3790
3791    /// Persist the live conversation to `path` as JSONL (one [`ChatMessage`]
3792    /// per line) so the session can be resumed later — supercode's own sessions
3793    /// become first-class, resumable artifacts.
3794    pub fn save_transcript(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
3795        let mut out = String::new();
3796        for m in &self.history {
3797            out.push_str(&serde_json::to_string(m).map_err(Error::Decode)?);
3798            out.push('\n');
3799        }
3800        std::fs::write(path, out)?;
3801        Ok(())
3802    }
3803
3804    /// Restore a conversation previously written with [`Self::save_transcript`],
3805    /// replacing the current history.
3806    pub fn load_transcript(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
3807        let text = std::fs::read_to_string(path)?;
3808        let mut history = Vec::new();
3809        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
3810            history.push(serde_json::from_str::<ChatMessage>(line).map_err(Error::Decode)?);
3811        }
3812        self.history = history;
3813        Ok(())
3814    }
3815
3816    /// Take a checkpoint of the current conversation position. Pass it to
3817    /// [`Self::rewind_to`] to discard everything sent since (the rewind/undo
3818    /// analog of `fork`/checkpoint).
3819    pub fn checkpoint(&self) -> usize {
3820        self.history.len()
3821    }
3822
3823    /// Rewind the conversation to a [`Self::checkpoint`], discarding later turns.
3824    pub fn rewind_to(&mut self, checkpoint: usize) {
3825        self.history.truncate(checkpoint.min(self.history.len()));
3826    }
3827
3828    /// Send a message with file inputs attached — the `--file` / `-i` analog.
3829    /// Each file's contents are injected into the prompt: UTF-8 text inline,
3830    /// binary (e.g. images) noted with a size marker. (Native image *vision*
3831    /// would additionally require multimodal content parts.)
3832    pub async fn send_with_files(
3833        &mut self,
3834        text: impl Into<String>,
3835        files: &[std::path::PathBuf],
3836    ) -> Result<String> {
3837        let mut prompt = text.into();
3838        for path in files {
3839            let block = match std::fs::read(path) {
3840                Ok(bytes) => match String::from_utf8(bytes.clone()) {
3841                    Ok(s) => format!("\n\n[file: {}]\n{}", path.display(), s),
3842                    Err(_) => format!(
3843                        "\n\n[file: {} — {} bytes, binary content omitted]",
3844                        path.display(),
3845                        bytes.len()
3846                    ),
3847                },
3848                Err(e) => format!("\n\n[file: {} — could not read: {e}]", path.display()),
3849            };
3850            prompt.push_str(&block);
3851        }
3852        let expanded = self.expand_prompt_async(&prompt).await;
3853        let msg = ChatMessage::user(expanded);
3854        self.guard_candidate_message(&msg)?;
3855        self.record(&msg)?;
3856        self.history.push(msg);
3857        self.run_loop().await
3858    }
3859
3860    /// Send a message with image inputs to a vision model — the `-i/--image`
3861    /// analog. `image_urls` may be `https://…` links or `data:image/…;base64,…`
3862    /// URLs; they're attached as multimodal `image_url` content parts.
3863    pub async fn send_with_images(
3864        &mut self,
3865        text: impl Into<String>,
3866        image_urls: &[String],
3867    ) -> Result<String> {
3868        let expanded = self.expand_prompt_async(&text.into()).await;
3869        let msg = ChatMessage::user_with_images(expanded, image_urls);
3870        self.guard_candidate_message(&msg)?;
3871        self.record(&msg)?;
3872        self.history.push(msg);
3873        self.run_loop().await
3874    }
3875
3876    /// Expand a `/<name> <args>` slash command against the registered prompt
3877    /// templates (`{args}` is replaced with the trailing text). Non-matching
3878    /// input is returned unchanged.
3879    /// BP-6 additionally resolves SKILL.md invocations here, after the
3880    /// template table misses: `/skill:name args` (pi§2 "Skill commands"),
3881    /// `/name args` when the config follows Claude Code (cc§7: "a `SKILL.md`
3882    /// in a directory = a `/name` command"), and `$slug` mentions (cx§7).
3883    /// `$ARGUMENTS` in the body is replaced with the trailing text.
3884    pub fn expand_prompt(&self, input: &str) -> String {
3885        // BP-5 (catalog D2 "@-file mentions / attachments"): `@path`
3886        // expansion happens FIRST, so a mention works in a bare message, in
3887        // a slash-command's arguments, and in the text a `$slug` mention
3888        // appends to — one rule, every prompt shape.
3889        let input = &self.expand_file_mentions(input);
3890        let trimmed = input.trim_start();
3891        let Some(rest) = trimmed.strip_prefix('/') else {
3892            return self.expand_skill_mentions(input);
3893        };
3894        let (name, args) = match rest.split_once(char::is_whitespace) {
3895            Some((n, a)) => (n, a.trim()),
3896            None => (rest, ""),
3897        };
3898        match self.config.prompts.get(name) {
3899            Some(template) => template.replace("{args}", args),
3900            None => match self.expand_skill_command(name, args) {
3901                Some(expanded) => expanded,
3902                None => self.expand_skill_mentions(input),
3903            },
3904        }
3905    }
3906
3907    /// BP-5 (catalog D2 "@-file mentions / attachments"; cc§2 "`@` in the
3908    /// prompt triggers file-path autocomplete and injects file context …
3909    /// Read deny rules best-effort apply to `@file` mentions"; cx§2
3910    /// "`@`-mentions (files)"): replace each `@path` token in `input` with
3911    /// that file's contents.
3912    ///
3913    /// **Deny-rule aware, through the one permissions engine.** Each
3914    /// mention is resolved with
3915    /// [`crate::permissions::evaluate_path_safe`] — the same
3916    /// traversal/symlink-resolving check a `read_file` tool call goes
3917    /// through — against this config's own rules and protected-path floor.
3918    /// Anything short of `Allow` inlines the refusal instead of the file, so
3919    /// `@.env` under a preset whose protected paths cover it says so rather
3920    /// than quietly leaking it.
3921    ///
3922    /// A token that names nothing readable is left exactly as the user typed
3923    /// it: an email address, a decorator, or a `@`-prefixed word in prose is
3924    /// not a file mention, and must survive untouched.
3925    /// Off by default (`[core.file_mentions]`).
3926    fn expand_file_mentions(&self, input: &str) -> String {
3927        if !self.config.file_mentions || !input.contains('@') {
3928            return input.to_string();
3929        }
3930        let mut attachments = String::new();
3931        let mut seen: Vec<String> = Vec::new();
3932        for token in input.split_whitespace() {
3933            let Some(rel) = token.strip_prefix('@') else {
3934                continue;
3935            };
3936            let rel = rel.trim_end_matches([',', ';', ':', '.', ')', ']', '"', '\'']);
3937            if rel.is_empty() || seen.iter().any(|s| s == rel) {
3938                continue;
3939            }
3940            let path = if std::path::Path::new(rel).is_absolute() {
3941                std::path::PathBuf::from(rel)
3942            } else {
3943                self.config.cwd.join(rel)
3944            };
3945            if !path.is_file() {
3946                continue;
3947            }
3948            seen.push(rel.to_string());
3949            attachments.push_str(&self.render_mention(rel, &path));
3950            if seen.len() >= MAX_FILE_MENTIONS_PER_MESSAGE {
3951                break;
3952            }
3953        }
3954        if attachments.is_empty() {
3955            return input.to_string();
3956        }
3957        format!("{input}{attachments}")
3958    }
3959
3960    /// One mention's block: the permission verdict first, then the bytes.
3961    /// Text is inlined; a binary file is named with its size, the same
3962    /// shape [`Self::send_with_files`] already uses for an explicit
3963    /// attachment, so a mention and a `--file` read the same way.
3964    fn render_mention(&self, shown: &str, path: &std::path::Path) -> String {
3965        use crate::permissions::{Decision, PathKind};
3966        let rules = crate::permissions::rules_for_config(&self.config);
3967        // BP-10's multi-root form: a mention is checked against every
3968        // granted root (cwd + `additional_dirs`), folded to the strictest —
3969        // the same call the tool-dispatch gate makes for a `read_file`
3970        // path, so a mention can never reach a file a read could not.
3971        let mut roots = vec![self.config.cwd.clone()];
3972        roots.extend(self.config.additional_dirs.iter().cloned());
3973        let decision = crate::permissions::evaluate_path_safe_roots(
3974            &rules,
3975            PathKind::Read,
3976            &roots,
3977            &path.to_string_lossy(),
3978            Decision::Allow,
3979        );
3980        if decision != Decision::Allow {
3981            return format!(
3982                "\n\n[file: {shown} — not attached; the permission rules for this session \
3983                 resolve reading it to {decision:?}]"
3984            );
3985        }
3986        match std::fs::read(path) {
3987            Ok(bytes) => match String::from_utf8(bytes) {
3988                Ok(text) => {
3989                    let mut text = text;
3990                    if text.len() > MAX_FILE_MENTION_BYTES {
3991                        let mut cut = MAX_FILE_MENTION_BYTES;
3992                        while cut > 0 && !text.is_char_boundary(cut) {
3993                            cut -= 1;
3994                        }
3995                        text.truncate(cut);
3996                        text.push_str("\n[file truncated]");
3997                    }
3998                    format!("\n\n[file: {shown}]\n{text}")
3999                }
4000                Err(e) => format!(
4001                    "\n\n[file: {shown} — {} bytes, binary content omitted]",
4002                    e.into_bytes().len()
4003                ),
4004            },
4005            Err(e) => format!("\n\n[file: {shown} — could not read: {e}]"),
4006        }
4007    }
4008
4009    /// The SKILL.md packages this agent discovered (frontmatter only) — the
4010    /// exact set its prompt index lists and its `skill` tool can load.
4011    pub fn skills(&self) -> &[crate::skills::LoopSkill] {
4012        &self.skills
4013    }
4014
4015    /// BP-6: resolve a slash command against the discovered skills.
4016    ///
4017    /// `/skill:<name>` is pi's own form and is accepted under every config
4018    /// (it can never collide with a template name, which cannot contain a
4019    /// colon-prefixed `skill` segment by construction). The BARE `/<name>`
4020    /// form is Claude Code's — there, a skill IS a slash command — so it is
4021    /// honored only when the config reads Claude Code's roots; under
4022    /// `cx-parity`, where Codex has no skill slash commands, `/deploy` stays
4023    /// the literal text the user typed.
4024    fn expand_skill_command(&self, name: &str, args: &str) -> Option<String> {
4025        if self.skills.is_empty() {
4026            return None;
4027        }
4028        let bare = match name.strip_prefix("skill:") {
4029            Some(rest) => rest,
4030            None if self.config.skills_harness.as_deref()
4031                == Some(crate::HarnessId::CLAUDE_CODE) =>
4032            {
4033                name
4034            }
4035            None => return None,
4036        };
4037        let skill = self.find_skill(bare)?;
4038        skill
4039            .body_with_shell(args, &self.shell_injection)
4040            .ok()
4041            .map(|body| crate::skills::render_skill(skill, &body))
4042    }
4043
4044    /// BP-6: `$slug` mentions (cx§7 `TOOL_MENTION_SIGIL = '$'`) and — only
4045    /// under `[core.skills] implicit_match` — a description match.
4046    ///
4047    /// The user's own text is never replaced: a loaded body is APPENDED, the
4048    /// way Codex splices a skill into the turn. Mentions are only honored
4049    /// for a config that reads Codex's roots; `$WORD` is ordinary shell text
4050    /// everywhere else.
4051    fn expand_skill_mentions(&self, input: &str) -> String {
4052        if self.skills.is_empty() {
4053            return input.to_string();
4054        }
4055        let mut loaded: Vec<String> = Vec::new();
4056        let mut names: Vec<String> = Vec::new();
4057        if self.config.skills_harness.as_deref() == Some(crate::HarnessId::CODEX) {
4058            for token in input.split_whitespace() {
4059                let Some(slug) = token.strip_prefix('$') else {
4060                    continue;
4061                };
4062                let slug =
4063                    slug.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != ':');
4064                if slug.is_empty() {
4065                    continue;
4066                }
4067                let Some(skill) = self.find_skill(slug) else {
4068                    continue;
4069                };
4070                if names.contains(&skill.name) || loaded.len() >= MAX_SKILL_LOADS_PER_MESSAGE {
4071                    continue;
4072                }
4073                if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
4074                    names.push(skill.name.clone());
4075                    loaded.push(crate::skills::render_skill(skill, &body));
4076                }
4077            }
4078        }
4079        if loaded.is_empty() && self.config.skills_implicit_match {
4080            if let Some(skill) = crate::skills::implicit_skill_match(&self.skills, input) {
4081                if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
4082                    loaded.push(crate::skills::render_skill(skill, &body));
4083                }
4084            }
4085        }
4086        if loaded.is_empty() {
4087            return input.to_string();
4088        }
4089        format!("{input}\n\n{}", loaded.join("\n\n"))
4090    }
4091
4092    /// Resolve one invocation name against the discovered set — the same
4093    /// resolver the `skill` tool uses, so every door agrees on what a name
4094    /// means.
4095    fn find_skill(&self, name: &str) -> Option<&crate::skills::LoopSkill> {
4096        crate::skills::find_skill(&self.skills, name)
4097    }
4098
4099    /// P5-2 (§2 module 15 D7 row 4 "prompts-as-commands"): like
4100    /// [`Self::expand_prompt`], but also consults MCP-server-sourced
4101    /// prompts registered via [`Self::register_mcp_prompt`] when the local
4102    /// `Config::prompts` table has no match — a live `prompts/get`
4103    /// round-trip, which is why this is async and [`Self::expand_prompt`]
4104    /// itself stays synchronous (its public sync signature is unchanged,
4105    /// for every existing caller that doesn't need MCP prompts).
4106    ///
4107    /// **Argument mapping (a scope decision, not a protocol requirement —
4108    /// the MCP spec leaves "how does free CLI text become named prompt
4109    /// arguments" to the client):** a prompt with zero or one declared
4110    /// arguments gets the whole trailing text (empty string if the prompt
4111    /// takes no arguments and none was given); a prompt with two or more
4112    /// declared arguments expects `key=value` pairs, whitespace-separated
4113    /// (`/mcp__server__prompt lang=rust topic=async`) — an unparseable pair
4114    /// (no `=`) is simply skipped, never a hard error (matches this
4115    /// method's "non-matching input passes through" fail-open posture for
4116    /// the LOCAL-prompt case above).
4117    pub async fn expand_prompt_async(&self, input: &str) -> String {
4118        let local = self.expand_prompt(input);
4119        if local != input {
4120            return local; // a local `Config::prompts` template matched
4121        }
4122        let trimmed = input.trim_start();
4123        let Some(rest) = trimmed.strip_prefix('/') else {
4124            return input.to_string();
4125        };
4126        let (name, args) = match rest.split_once(char::is_whitespace) {
4127            Some((n, a)) => (n, a.trim()),
4128            None => (rest, ""),
4129        };
4130        let Some(source) = self.mcp_prompts.get(name) else {
4131            return input.to_string();
4132        };
4133        let arg_map = match source.arg_names() {
4134            [] => std::collections::BTreeMap::new(),
4135            [single] => {
4136                let mut m = std::collections::BTreeMap::new();
4137                if !args.is_empty() {
4138                    m.insert(single.clone(), args.to_string());
4139                }
4140                m
4141            }
4142            _ => args
4143                .split_whitespace()
4144                .filter_map(|pair| pair.split_once('='))
4145                .map(|(k, v)| (k.to_string(), v.to_string()))
4146                .collect(),
4147        };
4148        match source.render(arg_map).await {
4149            Ok(rendered) => rendered,
4150            Err(e) => format!("Error: mcp prompt `{name}` failed: {e}"),
4151        }
4152    }
4153
4154    /// P5-2 (§2 module 15 D7 row 4): register an MCP server's prompt as a
4155    /// slash-command source — `command_name` MUST already be the
4156    /// namespaced `mcp__<server>__<prompt>` form
4157    /// ([`crate::mcp::McpServerHandle::prompts`] produces exactly that
4158    /// shape); this method does not re-namespace or validate it, so a
4159    /// caller that hands it a bare name defeats the collision protection
4160    /// [`crate::mcp::McpPromptSource`]'s doc comment describes. Overwrites
4161    /// any prior registration under the same command name (re-attaching
4162    /// the same server replaces its own earlier prompt list; this can
4163    /// never touch a NON-`mcp__`-prefixed key, i.e. never a local
4164    /// `Config::prompts` entry).
4165    pub fn register_mcp_prompt(
4166        &mut self,
4167        command_name: impl Into<String>,
4168        source: impl crate::sdk::SdkPromptSource + 'static,
4169    ) {
4170        self.mcp_prompts
4171            .insert(command_name.into(), Box::new(source));
4172    }
4173
4174    /// P5-2 (§2 module 15 D7 row 5 "instructions"): fold an MCP server's
4175    /// `initialize`-time instructions (or any other free-text note) into
4176    /// this agent's system message — the context-assembly site every other
4177    /// `core.*`/`capabilities.*` prompt-section append already uses
4178    /// (`Self::with_parts`), except this one fires AFTER construction
4179    /// (attaching MCP servers happens once the agent already exists — see
4180    /// `crates/cli/src/main.rs`'s `attach_mcp`). A no-op if `history` is
4181    /// somehow empty or its first message isn't a system message (never
4182    /// true for an `Agent` built via `Self::new`/`Self::with_parts`, but
4183    /// checked rather than assumed).
4184    pub fn append_system_note(&mut self, text: &str) {
4185        if let Some(system) = self.history.first_mut() {
4186            if system.role == Role::System {
4187                system
4188                    .content
4189                    .get_or_insert_with(String::new)
4190                    .push_str(text);
4191            }
4192        }
4193    }
4194
4195    /// BP-4 (catalog:90, cx§2 `<environment_context>` "re-emitted on
4196    /// change"): re-derive the `# Environment` block and, if anything in it
4197    /// moved — cwd, the approval/sandbox policy, the git branch or its
4198    /// dirty state, the date — replace the stale copy in the system message
4199    /// with the fresh one. Returns whether the block changed.
4200    ///
4201    /// A no-op (and free — no git subprocess) when `core.env_context` is
4202    /// off, which is the default and every non-parity config. Replacing in
4203    /// place rather than appending a second block is deliberate: two
4204    /// `# Environment` sections disagreeing about cwd is worse context than
4205    /// one stale one, and the system message is re-sent on every request,
4206    /// so the rewrite IS the re-emission the model sees.
4207    pub fn refresh_env_context(&mut self) -> bool {
4208        if !self.config.env_context {
4209            return false;
4210        }
4211        let fresh = env_context_block(&self.config);
4212        let Some(stale) = self.env_context_live.clone() else {
4213            // Nothing was spliced at construction (e.g. `with_provider_arc`);
4214            // splice it now rather than silently never emitting one.
4215            self.append_system_note(&fresh);
4216            self.env_context_live = Some(fresh);
4217            return true;
4218        };
4219        if stale == fresh {
4220            return false;
4221        }
4222        if let Some(system) = self.history.first_mut() {
4223            if system.role == Role::System {
4224                if let Some(content) = system.content.as_mut() {
4225                    if let Some(at) = content.find(&stale) {
4226                        content.replace_range(at..at + stale.len(), &fresh);
4227                        self.env_context_live = Some(fresh);
4228                        return true;
4229                    }
4230                }
4231            }
4232        }
4233        false
4234    }
4235
4236    /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): re-select
4237    /// the base prompt for the model now in force and replace the stale one
4238    /// in place. Returns whether the system message changed.
4239    ///
4240    /// A no-op — not even a string search — when the selection is unchanged,
4241    /// which is every config that sets no `base_prompts` table.
4242    fn refresh_base_prompt(&mut self) -> bool {
4243        let fresh = base_prompt_for_config(&self.config);
4244        if fresh == self.base_prompt_live {
4245            return false;
4246        }
4247        let stale = std::mem::replace(&mut self.base_prompt_live, fresh.clone());
4248        if stale.is_empty() {
4249            return false;
4250        }
4251        if let Some(system) = self.history.first_mut() {
4252            if system.role == Role::System {
4253                if let Some(content) = system.content.as_mut() {
4254                    if let Some(at) = content.find(&stale) {
4255                        content.replace_range(at..at + stale.len(), &fresh);
4256                        return true;
4257                    }
4258                }
4259            }
4260        }
4261        false
4262    }
4263
4264    /// BP-5: assemble the request from an already-built message list and
4265    /// tool-schema list. The ONE place a [`ChatRequest`] is constructed from
4266    /// this agent's config, so the request `Self::run_loop` issues and the
4267    /// request [`Self::model_input`] renders cannot drift apart.
4268    fn chat_request(&self, messages: Vec<ChatMessage>, tools: Vec<ToolSchema>) -> ChatRequest {
4269        let mut req = ChatRequest {
4270            model: self.config.model.clone(),
4271            messages,
4272            tools,
4273            temperature: self.config.temperature,
4274            max_tokens: self.config.max_tokens,
4275            effort: self.config.effort.clone(),
4276            response_format: self.config.response_format.clone(),
4277            service_tier: None,
4278            thinking_budget: None,
4279            extra_body: self.config.extra_body.clone(),
4280        };
4281        // BP-13 (catalog Domain 9): the per-request routing decisions —
4282        // effort LEVEL, thinking-token BUDGET and service TIER — all come
4283        // out of `Config::model_routing` keyed by the model this request is
4284        // actually going to. Applied HERE so `model_input`'s rendering and
4285        // the loop's own send can never disagree about what would be sent,
4286        // and so a mid-session switch re-decides all three for the new
4287        // model on the next pass.
4288        self.apply_routing(&mut req);
4289        req
4290    }
4291
4292    /// BP-5 (catalog D2 "Prompt-input debugging": *render the exact
4293    /// model-visible input for inspection*; cx§2 `codex debug prompt-input`,
4294    /// which "renders the exact model-visible input list as JSON"): the
4295    /// request this agent would send next.
4296    ///
4297    /// Built by the SAME two calls the loop makes
4298    /// ([`Self::build_request_messages`], [`Self::tool_schemas`]) and
4299    /// assembled by the SAME [`Self::chat_request`] — it is the real
4300    /// request, not a reconstruction of one. `&mut self` because
4301    /// `build_request_messages` is: rendering the input is exactly as
4302    /// stateful as building it for a send.
4303    pub fn model_input(&mut self) -> ChatRequest {
4304        let tools = self.tool_schemas();
4305        let messages = self.build_request_messages();
4306        self.chat_request(messages, tools)
4307    }
4308
4309    /// BP-5: [`Self::model_input`] for a turn that has not been sent —
4310    /// `prompt` is expanded exactly as [`Self::send`] would expand it
4311    /// (slash templates, skills, `@path` mentions, MCP prompts) and appended
4312    /// to the conversation IN MEMORY, then the request is rendered.
4313    ///
4314    /// Deliberately not recorded: this door inspects an input, it does not
4315    /// take a turn. Nothing is written to the session store, no journal
4316    /// entry is made, and no request is issued.
4317    pub async fn model_input_for(&mut self, prompt: &str) -> ChatRequest {
4318        let expanded = self.expand_prompt_async(prompt).await;
4319        self.history.push(ChatMessage::user(expanded));
4320        self.model_input()
4321    }
4322
4323    /// BP-5: a [`ChatRequest`] as the JSON a human (or `jq`) inspects — the
4324    /// system prompt, every message in order, and every advertised tool
4325    /// schema, plus the sampling controls that travel with them.
4326    pub fn render_model_input(req: &ChatRequest) -> serde_json::Value {
4327        serde_json::json!({
4328            "model": req.model,
4329            "temperature": req.temperature,
4330            "max_tokens": req.max_tokens,
4331            "effort": req.effort,
4332            "response_format": req.response_format,
4333            // Serialized through `ChatMessage`'s OWN wire serializer and
4334            // `ToolSchema`'s own — i.e. the exact bytes the provider is
4335            // handed, not a second rendering of them.
4336            "messages": serde_json::to_value(&req.messages).unwrap_or(serde_json::Value::Null),
4337            "tools": serde_json::to_value(&req.tools).unwrap_or(serde_json::Value::Null),
4338        })
4339    }
4340
4341    /// BP-5 (catalog D2 "Per-model-family base-prompt selection"): the base
4342    /// system prompt currently in force for this agent's model.
4343    pub fn base_prompt(&self) -> &str {
4344        &self.base_prompt_live
4345    }
4346
4347    /// BP-4 (catalog:91 "Synthetic context-injection blocks"): splice one
4348    /// named ambient block into the live context — the seam a hook's
4349    /// `additionalContext`, a frontend nudge or an orchestrator's brief
4350    /// enters through, mid-session, after construction.
4351    ///
4352    /// Requires `core.context_injections` (returns `false` otherwise): the
4353    /// gate governs the whole registry, not just its startup half. The
4354    /// block is appended to the system message and remembered, so it is
4355    /// carried by every later request and re-rendered by
4356    /// [`crate::context_injection::assemble`] wherever the prompt is
4357    /// rebuilt.
4358    pub fn inject_context_block(
4359        &mut self,
4360        name: impl Into<String>,
4361        content: impl Into<String>,
4362    ) -> bool {
4363        if !self.config.context_injections {
4364            return false;
4365        }
4366        let block = crate::config::ContextInjectionBlock::new(name, content);
4367        let rendered = crate::context_injection::render(std::slice::from_ref(&block));
4368        self.spliced_context_blocks.push(block);
4369        self.append_system_note(&rendered);
4370        true
4371    }
4372
4373    /// The blocks spliced in since construction — see
4374    /// [`Self::inject_context_block`].
4375    pub fn spliced_context_blocks(&self) -> &[crate::config::ContextInjectionBlock] {
4376        &self.spliced_context_blocks
4377    }
4378
4379    /// Compact the conversation if it has grown past the configured
4380    /// threshold.
4381    ///
4382    /// **Re-founded (A10):** with a [`ReductionPolicy`] installed
4383    /// ([`Self::set_reduction_policy`]), this no longer touches `self.history`
4384    /// at all. It derives `policy.clear_turns_older_than` from
4385    /// `compact_after_messages` so the *next* projected request view
4386    /// (`reduce::project_messages`, built in `Self::run_loop`) collapses the
4387    /// old turns into one reversible `TurnsCleared` stub instead —
4388    /// `history()` and the sidecar keep every message forever; only the view
4389    /// shrinks. Returns whether the live (unreduced) history currently
4390    /// exceeds the threshold, i.e. whether a clearing will actually be
4391    /// visible in the next projected view.
4392    ///
4393    /// **Legacy path (no policy) — LOSSY, kept only for byte-identical
4394    /// backward compatibility (D6):** destructively rewrites `self.history`,
4395    /// permanently discarding the dropped middle turns (replaced by a single
4396    /// non-reversible summary marker that becomes their SOLE remaining copy —
4397    /// exactly the lossy compaction this reduction layer differentiates
4398    /// against). Once a sidecar/recorder or a [`ReductionPolicy`] is in play,
4399    /// prefer installing a policy so this method takes the re-founded path
4400    /// above instead.
4401    pub fn maybe_compact(&mut self) -> bool {
4402        // P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
4403        // yet"): checked FIRST, before either trigger — `false` disables
4404        // every auto-compaction trigger unconditionally (message-count AND
4405        // pressure), composing with them rather than replacing their own
4406        // logic. `true` (the default, matching today's pre-P4e behavior,
4407        // where nothing ever gated compaction) falls straight through to
4408        // the existing trigger checks below, unchanged.
4409        if !self.config.compaction_enabled {
4410            return false;
4411        }
4412        let threshold = self.config.compact_after_messages;
4413        // P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape): a
4414        // SECOND, independent trigger — context-window pressure — alongside
4415        // (not instead of) the message-count one above. `None` (the
4416        // default) is byte-identical to today's message-count-only
4417        // behavior; this whole block is a no-op then.
4418        let message_trigger = threshold.is_some_and(|t| self.history.len() > t);
4419        let pressure_trigger = self.compaction_pressure_triggered();
4420        if threshold.is_none() && self.config.compaction_reserve_tokens.is_none() {
4421            return false;
4422        }
4423        if !message_trigger && !pressure_trigger {
4424            return false;
4425        }
4426        if let Some(policy) = self.reduction_policy.as_mut() {
4427            if let Some(t) = threshold {
4428                policy.clear_turns_older_than = Some(t);
4429            }
4430            // P4b scope note: the token-PRESSURE trigger's "how much to
4431            // clear" derivation (below, for the legacy in-place path) has no
4432            // `ReductionPolicy`/A10 analog yet — that mechanism decides its
4433            // own clearing window once `clear_turns_older_than` is set, so
4434            // pressure firing alone (no message threshold configured) has
4435            // nothing new to hand it in this pass. Report the message-count
4436            // verdict only, matching today's pre-P4b behavior exactly when
4437            // only `threshold` is set.
4438            return message_trigger;
4439        }
4440        // Legacy in-place compaction (no `ReductionPolicy` installed) below.
4441        // `keep_recent`: the message-count trigger's own `threshold / 2`
4442        // shape when it's what fired (or both fired); otherwise (pressure
4443        // fired alone) a token-budget-derived count.
4444        let keep_recent = if message_trigger {
4445            (threshold.unwrap() / 2).max(2)
4446        } else {
4447            self.keep_recent_count_by_tokens()
4448        };
4449        self.compact_in_place(keep_recent, None)
4450    }
4451
4452    /// BP-4 (catalog:98 "Manual compact with focus instructions", cc§2 /
4453    /// cx§2 `/compact [instructions]`): compact NOW, regardless of whether
4454    /// either automatic trigger has fired — the mechanism behind the REPL's
4455    /// `/compact [focus]`.
4456    ///
4457    /// `focus` is this invocation's steering text: it overrides the standing
4458    /// `core.compaction.focus_instructions` for this compaction only, is
4459    /// carried into the SUMMARIZER's input (so the model-written summary
4460    /// preserves what the user asked for), and is stated on the marker. An
4461    /// empty/whitespace `focus` falls back to the configured standing value,
4462    /// which is what a bare `/compact` means.
4463    ///
4464    /// Returns whether anything was compacted (`false` when the history is
4465    /// already at or below the keep-window, or when a [`ReductionPolicy`] is
4466    /// installed — under a policy the reversible A10 path owns clearing, and
4467    /// a manual compact would be the lossy one).
4468    pub fn compact_now(&mut self, focus: Option<&str>) -> bool {
4469        self.compacting_manually = true;
4470        let compacted = self.compact_now_inner(focus);
4471        self.compacting_manually = false;
4472        compacted
4473    }
4474
4475    fn compact_now_inner(&mut self, focus: Option<&str>) -> bool {
4476        if self.reduction_policy.is_some() {
4477            return false;
4478        }
4479        // A manual compact must actually compact. The token budget alone
4480        // (`core.compaction.keep_recent_tokens`, 20k) keeps EVERYTHING on
4481        // any ordinary conversation, which is right for the pressure
4482        // trigger (it fires only when the window is nearly full) and wrong
4483        // for `/compact`, whose whole point is compacting before the
4484        // pressure arrives. So the keep-window is the tighter of the two:
4485        // the token budget, and the message-count trigger's own established
4486        // "keep the most recent half" shape (`maybe_compact`'s
4487        // `threshold / 2`, floor 2).
4488        let keep_recent = self
4489            .keep_recent_count_by_tokens()
4490            .min((self.history.len() / 2).max(2));
4491        let focus = focus.map(str::trim).filter(|f| !f.is_empty());
4492        self.compact_in_place(keep_recent, focus)
4493    }
4494
4495    /// The legacy (no-[`ReductionPolicy`]) in-place compaction both
4496    /// [`Self::maybe_compact`] and [`Self::compact_now`] run: collapse
4497    /// `history[first..cut)` into one marker, keeping the newest
4498    /// `keep_recent` messages.
4499    fn compact_in_place(&mut self, keep_recent: usize, focus_override: Option<&str>) -> bool {
4500        if self.history.len() <= keep_recent {
4501            return false;
4502        }
4503        // Indices: 0 is the system prompt; collapse [first .. len-keep_recent).
4504        // `first` is 1 (only the system prompt is ever auto-preserved) unless
4505        // B7's coordination clamp widens it.
4506        let mut first = 1usize;
4507        // B7 coordination clamp: this legacy (no-`ReductionPolicy`) path
4508        // mutates `self.history` directly, so — unlike the re-founded A10
4509        // path (clamped inside `reduce::project_messages`, threaded from
4510        // `build_request_messages`) — it must clamp itself. Widening `first`
4511        // (not `cut`) is what actually protects the imported prefix: the
4512        // drop range is `[first, cut)`, so raising `cut` alone would only
4513        // drop MORE messages, not fewer. `imported_prefix_len` is already an
4514        // absolute `history` index count (it protects `history[0..len]`), so
4515        // no offset conversion is needed here.
4516        if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4517            if let Some(protected) = self.imported_prefix_len {
4518                first = first.max(protected);
4519            }
4520        }
4521        let mut cut = self.history.len() - keep_recent;
4522        if cut <= first {
4523            return false;
4524        }
4525        // Never begin the kept window on a tool result: its originating
4526        // assistant turn (with the matching `tool_calls`) is about to be
4527        // dropped, which would orphan the tool message and make the replayed
4528        // conversation invalid. Advance past any leading tool results.
4529        while cut < self.history.len() && self.history[cut].role == Role::Tool {
4530            cut += 1;
4531        }
4532        if cut >= self.history.len() {
4533            return false;
4534        }
4535        let dropped = cut - first;
4536        // BP-11: the compaction is decided from here on — the one point both
4537        // the automatic triggers and `/compact` pass through — so this is
4538        // where `pre_compact` observers hear about it.
4539        self.fire_lifecycle(&crate::config::LifecycleEvent::PreCompact {
4540            messages: self.history.len(),
4541            dropped,
4542            manual: focus_override.is_some() || self.compacting_manually,
4543        });
4544        // P4b (§1.5/§3.1 `core.compaction.focus_instructions`, catalog D2
4545        // "no instruction steering" gap): appended to the marker whenever
4546        // set, regardless of which trigger fired. `None` (the default)
4547        // leaves this byte-identical to the pre-P4b marker text.
4548        //
4549        // BP-4: `focus_override` — the per-invocation `/compact <focus>`
4550        // text — wins over the standing config value for THIS compaction,
4551        // which is what "`/compact [instructions]` steers what's preserved"
4552        // means. Neither is required.
4553        let focus: Option<String> = focus_override.map(str::to_string).or_else(|| {
4554            self.config
4555                .compaction_focus_instructions
4556                .clone()
4557                .filter(|f| !f.is_empty())
4558        });
4559        // BP-1 (§1.5/§3.1 `core.compaction.summarize`): the verb the marker
4560        // uses is now the config's to state. `true` (the default, and what
4561        // every preset sets) keeps the historical "summarized" text
4562        // byte-identical; `false` says only what actually happened to the
4563        // span, so a config that turns summarization off does not leave a
4564        // marker claiming a summary exists.
4565        let verb = if self.config.compaction_summarize {
4566            "summarized"
4567        } else {
4568            "cleared"
4569        };
4570        // BP-4 (catalog:107 "LLM summaries of cleared spans", design §1.5:
4571        // obligation 5 is "auto-compaction … + a persisted marker + AN LLM
4572        // SUMMARY OF THE COMPACTED SPAN", knob `[core.compaction] summarize`
4573        // — "the summary side-call depends on a utility model … core falls
4574        // back to the main model"). The side-call is therefore CORE, not a
4575        // reduction-module privilege: when `core.compaction.summarize` is on
4576        // and a summarizer is installed, the span is summarized by the model
4577        // and the marker carries that summary instead of only a count.
4578        //
4579        // Every failure mode degrades to the count-only marker: no
4580        // summarizer installed, an `Err` from the side-call, or an empty
4581        // reply. It never blocks or fails compaction — the same contract
4582        // TR-7's own side-call site keeps.
4583        let summary_body = if self.config.compaction_summarize {
4584            self.summarize_span(first..cut, focus.as_deref())
4585        } else {
4586            None
4587        };
4588        // BP-4 (catalog:99 "Compaction markers persisted in transcript"):
4589        // the marker states where the originals went, which is the whole
4590        // point of a boundary record — a reader must be able to tell a
4591        // reversible compaction from a lossy one without knowing which
4592        // modules were on.
4593        let retention = if self.recorder.is_some() {
4594            "The compacted messages remain in this session's transcript sidecar."
4595        } else {
4596            "No transcript sidecar is attached, so this marker is the only remaining record of them."
4597        };
4598        let mut summary_text = format!(
4599            "[earlier conversation compacted: {dropped} message(s) {verb} to save context]\n{retention}"
4600        );
4601        if let Some(focus) = &focus {
4602            summary_text.push_str(&format!("\n\nFocus: {focus}"));
4603        }
4604        if let Some(body) = &summary_body {
4605            summary_text.push_str(&format!("\n\nSummary of the compacted span:\n{body}"));
4606        }
4607        let summary = ChatMessage::system(summary_text);
4608        // BP-4 (catalog:99): PERSIST the boundary. Before this the legacy
4609        // path rewrote `self.history` and never called `record`, so the
4610        // marker existed only in the live window and a resumed session had
4611        // no on-disk trace that a compaction ever happened. A recorder
4612        // failure is logged, never fatal — losing the boundary record must
4613        // not lose the compaction.
4614        if let Err(error) = self.record(&summary) {
4615            tracing::warn!("failed to persist the compaction marker: {error}");
4616        }
4617        let mut new_history = Vec::with_capacity(first + keep_recent + 2);
4618        new_history.extend(self.history[..first].iter().cloned());
4619        new_history.push(summary);
4620        new_history.extend(self.history.split_off(cut));
4621        self.history = new_history;
4622        self.fire_lifecycle(&crate::config::LifecycleEvent::PostCompact {
4623            messages: self.history.len(),
4624            dropped,
4625        });
4626        // BP-8 (catalog:150): compaction RESHAPES the live view rather than
4627        // appending to it, so the journal's "everything since the last
4628        // checkpoint is unpersisted" accounting has to be re-based here —
4629        // otherwise a crash-recovery replay would re-append messages this
4630        // compaction deliberately set aside. The set-aside messages' own
4631        // bytes stay in the log above, untouched.
4632        self.journal_checkpoint(self.history.len());
4633        true
4634    }
4635
4636    /// BP-4 (catalog:107): run the installed [`reduce::summarize::SpanSummarizer`]
4637    /// over `history[span]`, with `focus` (the `/compact <focus>` text)
4638    /// carried into the summarizer's INPUT so the model-written summary
4639    /// preserves what the user asked to keep.
4640    ///
4641    /// `None` — never an error — whenever no summarizer is installed, the
4642    /// span renders empty, the side-call fails, or it returns nothing. The
4643    /// caller falls back to the count-only marker.
4644    fn summarize_span(&self, span: std::ops::Range<usize>, focus: Option<&str>) -> Option<String> {
4645        let summarizer = self.span_summarizer.as_deref()?;
4646        let mut span_text = String::new();
4647        // The focus rides at the head of the span text (the trait's one
4648        // input) as an explicit, labeled line rather than a silent prompt
4649        // mutation: the fixed prompt's "do not state anything not present
4650        // in the span" still holds, because the focus IS present in it.
4651        if let Some(focus) = focus {
4652            span_text.push_str(&format!("[compaction focus requested: {focus}]\n\n"));
4653        }
4654        for msg in self.history.get(span)? {
4655            let role = match msg.role {
4656                Role::System => "system",
4657                Role::User => "user",
4658                Role::Assistant => "assistant",
4659                Role::Tool => "tool",
4660            };
4661            span_text.push_str(role);
4662            span_text.push_str(": ");
4663            span_text.push_str(msg.content.as_deref().unwrap_or(""));
4664            span_text.push('\n');
4665        }
4666        match summarizer.summarize(&span_text) {
4667            Ok(text) if !text.trim().is_empty() => Some(text.trim().to_string()),
4668            Ok(_) => None,
4669            Err(error) => {
4670                tracing::warn!("compaction span summarizer failed: {error}");
4671                None
4672            }
4673        }
4674    }
4675
4676    /// BP-4 (catalog:106 "Handoff (fresh objective + curated keep-set)",
4677    /// cx§1 `new_context`): reset the live working view to a fresh
4678    /// objective plus a curated keep-set, in-session.
4679    ///
4680    /// The new view is: the system prompt (plus any imported prefix a
4681    /// `CachePlan::ImportedPrefix` config protects — same clamp compaction
4682    /// uses), then a handoff marker stating the objective and what was set
4683    /// aside, then the most recent `keep_recent` messages (`None` = the
4684    /// token-budget-derived count `core.compaction.keep_recent_tokens`
4685    /// already governs, so the keep-set is curated by the same budget the
4686    /// rest of the compaction machinery uses, not by a magic number). The
4687    /// keep-set never begins on a tool result, so no tool message is left
4688    /// orphaned from its originating assistant turn.
4689    ///
4690    /// Returns how many messages were set aside. Like compaction, the
4691    /// marker is PERSISTED through the recorder, so a resumed session can
4692    /// see where the handoff happened; and like compaction, the set-aside
4693    /// messages remain in the transcript sidecar whenever one is attached.
4694    ///
4695    /// Scope note: this is the in-session `new_context` mechanism, NOT
4696    /// `Config::handoff_enabled`'s reversible ReductionLog snapshot (the
4697    /// offline `supercode handoff` projection) — that one is the reduction
4698    /// module's, and stays there.
4699    pub fn new_context(&mut self, objective: &str, keep_recent: Option<usize>) -> usize {
4700        let keep_recent = keep_recent.unwrap_or_else(|| self.keep_recent_count_by_tokens());
4701        let mut first = 1usize;
4702        if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4703            if let Some(protected) = self.imported_prefix_len {
4704                first = first.max(protected);
4705            }
4706        }
4707        let mut cut = self.history.len().saturating_sub(keep_recent).max(first);
4708        while cut < self.history.len() && self.history[cut].role == Role::Tool {
4709            cut += 1;
4710        }
4711        let dropped = cut.saturating_sub(first);
4712        let objective = objective.trim();
4713        let retention = if self.recorder.is_some() {
4714            "They remain in this session's transcript sidecar."
4715        } else {
4716            "No transcript sidecar is attached, so they are not retained."
4717        };
4718        let marker = ChatMessage::system(format!(
4719            "[handoff: a fresh working context starts here]\nObjective: {objective}\n\
4720             {dropped} earlier message(s) were set aside; the most recent {kept} were kept. \
4721             {retention}",
4722            kept = self.history.len() - cut,
4723        ));
4724        if let Err(error) = self.record(&marker) {
4725            tracing::warn!("failed to persist the handoff marker: {error}");
4726        }
4727        let mut new_history = Vec::with_capacity(first + keep_recent + 2);
4728        new_history.extend(self.history[..first].iter().cloned());
4729        new_history.push(marker);
4730        new_history.extend(self.history.split_off(cut));
4731        self.history = new_history;
4732        // BP-8: same re-basing as `maybe_compact` — see its comment.
4733        self.journal_checkpoint(self.history.len());
4734        dropped
4735    }
4736
4737    /// P4b (§1.5/§3.1 `core.compaction.reserve_tokens`, pi§2 shape:
4738    /// `contextTokens > contextWindow - reserveTokens`): whether the
4739    /// estimated token size of the live history is within `reserve_tokens`
4740    /// of the model's context window. `false` when
4741    /// [`Config::compaction_reserve_tokens`] is unset (the default).
4742    fn compaction_pressure_triggered(&self) -> bool {
4743        let Some(reserve) = self.config.compaction_reserve_tokens else {
4744            return false;
4745        };
4746        let limit = provider::model_context_limit(&self.config.model)
4747            .unwrap_or(provider::UNKNOWN_MODEL_CONTEXT_FLOOR);
4748        let used = crate::tokens::estimate_view_tokens(&self.history);
4749        used.saturating_add(reserve) > limit
4750    }
4751
4752    /// P4b (§1.5/§3.1 `core.compaction.keep_recent_tokens`): how many of the
4753    /// most recent messages (walking backward from the end of `self.history`,
4754    /// skipping the system prompt) fit within the configured token budget
4755    /// (default 20,000, pi§6 precedent). Always keeps at least 2 messages,
4756    /// matching the message-count trigger's own floor.
4757    fn keep_recent_count_by_tokens(&self) -> usize {
4758        let budget = self.config.compaction_keep_recent_tokens.unwrap_or(20_000);
4759        let mut used = 0u64;
4760        let mut count = 0usize;
4761        for msg in self.history.iter().skip(1).rev() {
4762            let t = crate::tokens::estimate_view_tokens(std::slice::from_ref(msg));
4763            if used.saturating_add(t) > budget && count > 0 {
4764                break;
4765            }
4766            used = used.saturating_add(t);
4767            count += 1;
4768        }
4769        count.max(2)
4770    }
4771
4772    /// Register an additional tool (e.g. your own capability).
4773    ///
4774    /// P5-2 (§2.2 C2 "connect invalidates cache prefix"): registering a
4775    /// tool AFTER this agent has already issued a request
4776    /// ([`Self::request_issued`]) changes the tools schema every
4777    /// subsequent request carries — the exact prefix-churn shape C2
4778    /// describes, MCP-sourced or not. Resets [`Self::cache_established`] so
4779    /// the next cache-warmth check (`provider::cache_cold_reason`) doesn't
4780    /// wrongly assume the entry is still warm. A no-op call before the
4781    /// first request (the common case: `attach_mcp` registers tools once at
4782    /// startup, before any turn runs) changes nothing — byte-identical to
4783    /// today.
4784    pub fn register_tool(&mut self, tool: impl crate::tools::Tool + 'static) {
4785        self.registry.register(tool);
4786        if self.requests_issued {
4787            self.cache_established = false;
4788        }
4789    }
4790
4791    /// The current conversation, including the system prompt.
4792    pub fn history(&self) -> &[ChatMessage] {
4793        &self.history
4794    }
4795
4796    /// Send a user message and run the loop until the model produces a final
4797    /// answer (text with no tool calls) or the iteration budget is exhausted.
4798    pub async fn send(&mut self, user_input: impl Into<String>) -> Result<String> {
4799        let expanded = self.expand_prompt_async(&user_input.into()).await;
4800        let msg = ChatMessage::user(expanded);
4801        self.guard_candidate_message(&msg)?;
4802        self.record(&msg)?;
4803        self.history.push(msg);
4804        self.run_loop().await
4805    }
4806
4807    /// BP-4 (catalog:109 "Context-usage introspection", cc§2 `/context`
4808    /// grid, cx§8 `/status` + `get_context_remaining`): the LIVE
4809    /// context-window accounting for this session — the same numbers
4810    /// `resume --dry-run`'s preflight already computes
4811    /// (`tokens::estimate_request_tokens` / `tokens::context_guard`), read
4812    /// out mid-session instead of only before one.
4813    ///
4814    /// Pure: it projects the request view exactly as
4815    /// [`Self::guard_candidate_message`] does (reduction stubs included,
4816    /// cache annotation included) without mutating the reduction log, so
4817    /// asking "how full am I?" can never change what the next request
4818    /// carries.
4819    pub fn context_usage(&self) -> ContextUsage {
4820        let messages = self.projected_view(None);
4821        let tools = self.tool_schemas();
4822        let message_tokens = crate::tokens::estimate_view_tokens(&messages);
4823        let request_tokens = crate::tokens::estimate_request_tokens(&messages, &tools);
4824        let limit = self.context_limit.or_else(|| {
4825            crate::provider::model_context_limit(&self.config.model)
4826                .or(Some(crate::provider::UNKNOWN_MODEL_CONTEXT_FLOOR))
4827        });
4828        let projected_tokens = crate::tokens::with_guard_margin(request_tokens);
4829        let reserve = crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS;
4830        let (fits, remaining_tokens, used_pct) = match limit {
4831            Some(limit) => (
4832                projected_tokens.saturating_add(reserve) <= limit,
4833                limit
4834                    .saturating_sub(reserve)
4835                    .saturating_sub(projected_tokens),
4836                if limit == 0 {
4837                    0
4838                } else {
4839                    (projected_tokens as f64 / limit as f64 * 100.0).round() as u32
4840                },
4841            ),
4842            None => (true, 0, 0),
4843        };
4844        ContextUsage {
4845            model: self.config.model.clone(),
4846            messages: messages.len(),
4847            message_tokens,
4848            tool_count: tools.len(),
4849            tool_schema_tokens: request_tokens.saturating_sub(message_tokens),
4850            request_tokens,
4851            projected_tokens,
4852            response_reserve_tokens: reserve,
4853            context_limit: limit,
4854            remaining_tokens,
4855            used_pct,
4856            fits,
4857        }
4858    }
4859
4860    /// The messages a request would carry right now — the read-only half of
4861    /// [`Self::guard_candidate_message`]/[`Self::build_request_messages`],
4862    /// with `candidate` optionally appended as a not-yet-committed turn.
4863    /// Never mutates `self`.
4864    fn projected_view(&self, candidate: Option<&ChatMessage>) -> Vec<ChatMessage> {
4865        let messages = match &self.reduction_policy {
4866            None => {
4867                let mut messages = self.history.clone();
4868                if let Some(candidate) = candidate {
4869                    messages.push(candidate.clone());
4870                }
4871                messages
4872            }
4873            Some(policy) => {
4874                let has_system = self.history.first().is_some_and(|m| m.role == Role::System);
4875                let mut reducible = self.history[usize::from(has_system)..].to_vec();
4876                if let Some(candidate) = candidate {
4877                    reducible.push(candidate.clone());
4878                }
4879                let mut prepared = policy.clone();
4880                reduce::prepare_read_freshness(&mut prepared, &reducible);
4881                let (view, _) =
4882                    reduce::project_messages(&reducible, &prepared, &self.reduction_log);
4883                let mut messages = Vec::with_capacity(view.len() + usize::from(has_system));
4884                if has_system {
4885                    messages.push(self.history[0].clone());
4886                }
4887                messages.extend(view);
4888                messages
4889            }
4890        };
4891        provider::apply_cache_plan(&messages, self.config.cache_plan, self.imported_prefix_len)
4892    }
4893
4894    /// Refuse an oversized new user turn before it mutates canonical history
4895    /// or an attached sidecar. The in-loop guard remains authoritative for
4896    /// every actual request; this preflight closes the first-request seam
4897    /// where `send*` used to record/push the message before that guard ran.
4898    fn guard_candidate_message(&self, msg: &ChatMessage) -> Result<()> {
4899        let Some(limit) = self.context_limit else {
4900            return Ok(());
4901        };
4902
4903        let messages = self.projected_view(Some(msg));
4904        let tools = self.tool_schemas();
4905        let (fits, projected_tokens) = crate::tokens::context_guard(&messages, &tools, limit);
4906        if !fits {
4907            return Err(Error::ContextLimitExceeded {
4908                projected_tokens,
4909                reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
4910                context_limit: limit,
4911                model: self.config.model.clone(),
4912            });
4913        }
4914        Ok(())
4915    }
4916
4917    /// The messages a provider request should carry for the CURRENT turn
4918    /// (A5/A7/A8/A10): with no [`ReductionPolicy`] installed, exactly
4919    /// `self.history.clone()` — byte-identical to every version of this
4920    /// method before reduction landed. With a policy installed, `history[0]`
4921    /// (this agent's own system prompt, never a reduction target) followed by
4922    /// [`reduce::project_messages`]'s projected view of `history[1..]`, fed
4923    /// with `self.reduction_log` so already-applied reductions reproduce
4924    /// verbatim across turns (prefix stability, A5) — the updated log is
4925    /// stored back onto `self` so the NEXT call (this turn, next turn, or a
4926    /// later `send`) sees the same accumulating state. `self.history` itself
4927    /// is never read back into or mutated by this: it stays the full
4928    /// canonical view, in lockstep with the sidecar (A3).
4929    ///
4930    /// When `policy.elide_stale_reads` is set, this re-runs
4931    /// [`reduce::probe_read_freshness`] (the one place A8's disk I/O happens)
4932    /// against `history[1..]` before projecting, so every request sees
4933    /// up-to-date freshness verdicts — `project_messages` itself stays pure.
4934    ///
4935    /// Finally, B7's [`provider::apply_cache_plan`] runs over the assembled
4936    /// view (regardless of whether a [`ReductionPolicy`] is installed) — a
4937    /// pure, cloning annotation step, so this method's `&mut self` mutations
4938    /// above (`self.reduction_log`) are already committed before it runs and
4939    /// its own output is never written back onto `self.history` or the log:
4940    /// purity for B7's cache breakpoints holds independently of A5's.
4941    fn build_request_messages(&mut self) -> Vec<ChatMessage> {
4942        let messages = match self.reduction_policy.clone() {
4943            None => self.history.clone(),
4944            Some(mut policy) => {
4945                reduce::prepare_read_freshness(&mut policy, &self.history[1..]);
4946                // B7 coordination clamp: while `CachePlan::ImportedPrefix` is
4947                // active, A10 turn-clearing must never establish a range
4948                // that dips into the imported prefix (protects the cache
4949                // breakpoint the request build will place there below).
4950                // `imported_prefix_len` counts `history[0]` (this agent's own
4951                // system message) plus the imported messages, but
4952                // `project_messages` only ever sees `history[1..]` — hence
4953                // the `- 1`.
4954                if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
4955                    policy.protect_imported_prefix =
4956                        self.imported_prefix_len.map(|n| n.saturating_sub(1));
4957                }
4958                // TR-7 (T20): the one side-call site, run BEFORE
4959                // `project_messages` (which stays pure/I-O-free) — mirrors
4960                // `elide_stale_reads`/`probe_read_freshness` immediately
4961                // above. Only ever does anything when both the policy gate
4962                // AND a summarizer are present; either being absent means
4963                // `cleared_turns_summary` stays `None` and `project_messages`
4964                // renders the deterministic stub, same as before TR-7
4965                // existed.
4966                if policy.summarize_cleared_turns {
4967                    if let Some(summarizer) = self.span_summarizer.as_deref() {
4968                        policy.cleared_turns_summary = reduce::prepare_cleared_turns_summary(
4969                            &self.history[1..],
4970                            &policy,
4971                            &self.reduction_log,
4972                            summarizer,
4973                        );
4974                    }
4975                }
4976                let (view, log) =
4977                    reduce::project_messages(&self.history[1..], &policy, &self.reduction_log);
4978                self.reduction_log = log;
4979                let mut messages = Vec::with_capacity(view.len() + 1);
4980                messages.push(self.history[0].clone());
4981                messages.extend(view);
4982                messages
4983            }
4984        };
4985        // TR-8 (T5): a tool-schema tier change since the last request is a
4986        // cache-bust event under `CachePlan::ImportedPrefix` — the `tools`
4987        // array is part of the cache key alongside `messages`, so flag it by
4988        // skipping this one request's cache annotation rather than claiming
4989        // a prefix hit that won't actually land. Recorded unconditionally
4990        // (even under `CachePlan::Off`) so the signature stays current
4991        // regardless of which plan is active.
4992        let tier_sig = self.schema_tier_signature();
4993        let busted =
4994            provider::tier_change_is_cache_bust(self.last_tool_schema_tier_signature, tier_sig);
4995        self.last_tool_schema_tier_signature = Some(tier_sig);
4996        let effective_cache_plan = if busted {
4997            CachePlan::Off
4998        } else {
4999            self.config.cache_plan
5000        };
5001        // UX-26 (B7-warn): mirror `apply_cache_plan`'s own placement gate
5002        // (`ImportedPrefix` AND a non-zero prefix) to know whether THIS
5003        // request will actually carry a `cache_control` annotation. `busted`
5004        // requests (schema-tier change) and `CachePlan::Off` never annotate,
5005        // so `provider::cache_cold_reason` can never flag them — there was
5006        // nothing to reuse, by construction. `idle_secs` is computed
5007        // whenever a signal exists at all (even before this agent's first
5008        // annotated send — see `Self::last_cache_activity_ms`'s doc comment
5009        // on why the pre-establishment case matters); `cache_established`
5010        // additionally gates the usage-ratio check specifically (see
5011        // `provider::cache_cold_reason`'s doc comment for why those two
5012        // checks need independent gates).
5013        let will_annotate = matches!(effective_cache_plan, CachePlan::ImportedPrefix)
5014            && self.imported_prefix_len.is_some_and(|n| n > 0);
5015        let idle_secs = self
5016            .last_cache_activity_ms
5017            .map(|last| (now_ms() - last).max(0) / 1000);
5018        self.pending_cache_turn = (will_annotate, self.cache_established, idle_secs);
5019        let mut messages =
5020            provider::apply_cache_plan(&messages, effective_cache_plan, self.imported_prefix_len);
5021        // BP-7 (catalog §4a "Goals"): the standing objective, restated at
5022        // the TAIL of the request — after the cache annotation, which sits
5023        // on the PREFIX, so a goal that changes mid-session never busts the
5024        // cached prefix. Request-view only: `history` is untouched, so the
5025        // persisted transcript is exactly the conversation and a translator
5026        // never has to invent a message for a harness-tracked goal.
5027        if let Some(goal) = &self.goal {
5028            messages.push(ChatMessage::system(goal.reminder()));
5029        }
5030        messages
5031    }
5032
5033    /// Run the model/tool loop over the current history until a final answer or
5034    /// the iteration budget is exhausted. (Shared by `send`, `send_with_files`,
5035    /// and `send_with_images`.)
5036    /// P4b (§1.7, pi§3 semantics): pop the next message(s) to deliver from
5037    /// `queue` per `mode` — `All` drains everything and joins it with a
5038    /// blank line, `OneAtATime` pops exactly one. `None` when `queue` is
5039    /// empty (the default state, at zero cost).
5040    fn drain_steer_queue(
5041        queue: &mut std::collections::VecDeque<String>,
5042        mode: SteeringMode,
5043    ) -> Option<String> {
5044        if queue.is_empty() {
5045            return None;
5046        }
5047        match mode {
5048            SteeringMode::All => Some(queue.drain(..).collect::<Vec<_>>().join("\n\n")),
5049            SteeringMode::OneAtATime => queue.pop_front(),
5050        }
5051    }
5052
5053    async fn run_loop(&mut self) -> Result<String> {
5054        let _steer_turn = SteerTurnGuard::new(self.steer_queue.clone());
5055        let mut output_tokens_used: u64 = 0;
5056
5057        // BP-7 (catalog §4a "Turn/budget caps"): the SPEND cap, checked
5058        // before this `send` can issue anything. Unlike
5059        // `max_total_output_tokens` (a per-`send` allowance, unchanged),
5060        // spend accumulates over the agent's whole lifetime — a dollar
5061        // budget that resets on every prompt is not a budget. A cap reached
5062        // MID-loop ends that loop cleanly with a `spend_budget` finish
5063        // marker (below); a cap already exhausted at entry is an error,
5064        // because there is nothing to return.
5065        if let Some(budget) = self.config.max_budget_usd.filter(|b| *b > 0.0) {
5066            if self.total_cost_usd >= budget {
5067                return Err(Error::BudgetExhausted {
5068                    spent_usd: self.total_cost_usd,
5069                    budget_usd: budget,
5070                });
5071            }
5072        }
5073
5074        // P5-9 (§2 module 20, cc's "per-prompt file-history-snapshot"):
5075        // open a fresh checkpoint for THIS turn — `run_loop` is called
5076        // exactly once per `send`/`send_with_files`/`send_with_images`
5077        // call (never recursively for the same turn), so this fires once
5078        // per user prompt, matching the design's per-prompt granularity.
5079        // `self.history.last()` is the user message that call just pushed.
5080        // `None` (`checkpoint_observer` unset, the default) is a no-op —
5081        // zero cost, no disk touched.
5082        if let Some(cp) = &self.checkpoint_observer {
5083            let label = self
5084                .history
5085                .last()
5086                .and_then(|m| m.content.as_deref())
5087                .unwrap_or("")
5088                .to_string();
5089            cp.begin_turn(&label);
5090        }
5091
5092        // BP-4 (catalog:90, cx§2 "re-emitted on change"): once per user
5093        // turn — not per loop iteration — re-derive the environment block
5094        // so a cwd change, an approval/sandbox policy change or a branch
5095        // switch since the last turn reaches the model instead of leaving
5096        // it reading the startup snapshot. A no-op, with no subprocess, for
5097        // every config that doesn't set `core.env_context`.
5098        self.refresh_env_context();
5099
5100        for _ in 0..self.config.max_iterations {
5101            // BP-8 (catalog:156): flush a plan `update_plan` wrote during
5102            // the previous iteration's tool calls. A no-op when
5103            // `todos.persist` is off or the plan did not change.
5104            self.journal_plan_if_changed();
5105            // BP-7: the index of the round-trip this iteration is about to
5106            // make. Captured here because `self.turn_index` advances the
5107            // moment the usage record is written, and every marker in this
5108            // iteration — including the ones written after that point —
5109            // must carry the SAME index, or the marker log would not join
5110            // to the usage log on `turn`.
5111            let round_trip = self.turn_index;
5112            self.maybe_compact();
5113            // P4b (§1.7, pi§3 "steer = after current tool calls"): drain any
5114            // queued mid-turn steering message(s) BEFORE building the next
5115            // request — the top of every loop iteration is exactly "after
5116            // whatever tool calls the previous iteration just ran" (or, on
5117            // the very first iteration, before anything has happened yet,
5118            // which is an equally valid "deliver immediately" reading).
5119            // Empty queue (today's default state) is a no-op.
5120            let (steer_msg, steer_taken) = {
5121                let mut inbox = self
5122                    .steer_queue
5123                    .lock()
5124                    .unwrap_or_else(std::sync::PoisonError::into_inner);
5125                let before = inbox.len();
5126                let drained = inbox.drain(self.config.steering_mode);
5127                let taken = before - inbox.len();
5128                (drained, taken)
5129            };
5130            if let Some(steer_msg) = steer_msg {
5131                // BP-8 (catalog:154): the queue record's other half —
5132                // without it a replayed journal would keep re-delivering an
5133                // input the conversation already consumed.
5134                self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
5135                let msg = ChatMessage::user(steer_msg);
5136                self.record(&msg)?;
5137                self.history.push(msg);
5138            }
5139            // Recomputed every iteration (not hoisted): under `Deferred`
5140            // advertising, a `tool_search` call earlier in this same loop
5141            // activates tools that must be advertised starting with the very
5142            // next request (B6).
5143            let tools = self.tool_schemas();
5144            let messages = self.build_request_messages();
5145
5146            // PARITY-18 D4 — re-check the context guard before EVERY
5147            // request this loop builds, not just the caller's one-shot
5148            // preflight: interactive turns 2+, `/expand all`, and any
5149            // mid-loop tool round-trip that grows `messages` can push a
5150            // barely-passing session over the limit between sends. Only
5151            // armed when a caller has opted in via `set_context_limit`.
5152            // Uses the exact same `tokens::context_guard`
5153            // formula the CLI preflight uses, so the two can never disagree.
5154            if let Some(limit) = self.context_limit {
5155                let (fits, projected_tokens) =
5156                    crate::tokens::context_guard(&messages, &tools, limit);
5157                if !fits {
5158                    return Err(Error::ContextLimitExceeded {
5159                        projected_tokens,
5160                        reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
5161                        context_limit: limit,
5162                        model: self.config.model.clone(),
5163                    });
5164                }
5165            }
5166
5167            // BP-7 (catalog §4a "Turn/step bracketing records"): the
5168            // OPENING bracket, written before the request is issued so it
5169            // survives a request that never returns (a cancelled turn keeps
5170            // its `context` marker with no `usage`/`finish` after it).
5171            // Uses `tokens::estimate_request_tokens` — the same estimator
5172            // the context guard above uses, so the two can never disagree.
5173            self.push_turn_marker_at(
5174                round_trip,
5175                crate::turn_record::TurnMarker::Context {
5176                    messages: messages.len(),
5177                    tools: tools.len(),
5178                    estimated_tokens: crate::tokens::estimate_request_tokens(&messages, &tools),
5179                },
5180            );
5181
5182            let mut req = self.chat_request(messages, tools);
5183
5184            let fallback_hops: Vec<FallbackHop>;
5185            let completion = {
5186                let sink = self.config.event_sink.as_ref();
5187                let on_delta = move |s: &str| {
5188                    if let Some(sink) = sink {
5189                        sink(AgentEvent::TextDelta(s.to_string()));
5190                    }
5191                };
5192                // PARITY-18 D3 — the real send site: flip the flag
5193                // immediately before issuing the request, regardless of
5194                // whether `complete` then succeeds or fails, so
5195                // `request_issued()` truthfully reflects "a live request
5196                // was attempted" rather than "the run reached this line and
5197                // later succeeded."
5198                self.requests_issued = true;
5199                // P4b (§1.1/§3.1 `core.retry`, pi§3 shape): retry-with-
5200                // backoff already lives at the TRANSPORT layer
5201                // (`provider::OpenAiProvider::send_with_retry`, pre-existing
5202                // — connection failures and 5xx responses are retried
5203                // there); `Config.retry_*` (see `Agent::new`) makes that
5204                // EXISTING mechanism config-file-settable instead of
5205                // duplicating a second retry loop here, which would nest
5206                // retries confusingly on top of the transport's own.
5207                // BP-13 (D9 "Failure fallback model chains"): the chain is
5208                // EXECUTED here, not merely resolved. On a failure another
5209                // model could plausibly answer (overload / rate limit /
5210                // unavailability — `is_failover_worthy`), the request is
5211                // re-sent against the next entry of
5212                // `Config::model_fallback`, with routing re-applied for
5213                // that model. A 4xx that is not a rate limit is the caller's
5214                // problem, not the model's, and is never retried elsewhere.
5215                // The transport-level retry above has already run and given
5216                // up by the time a hop is considered.
5217                let (result, hops) = self.complete_with_fallback(&mut req, &on_delta).await;
5218                fallback_hops = hops;
5219                result
5220            };
5221            // BP-7: drained whether the request succeeded or failed, and
5222            // BEFORE the `?` — a request that exhausted its retries and
5223            // then errored is exactly the case a retry record exists for.
5224            for notice in self.retry_log.drain() {
5225                self.emit(AgentEvent::ProviderRetry {
5226                    attempt: notice.attempt,
5227                    delay_ms: notice.delay_ms,
5228                    reason: notice.reason.clone(),
5229                });
5230                self.push_turn_marker_at(
5231                    round_trip,
5232                    crate::turn_record::TurnMarker::Retry {
5233                        attempt: notice.attempt,
5234                        delay_ms: notice.delay_ms,
5235                        reason: notice.reason,
5236                    },
5237                );
5238            }
5239            // The switch the fallback pass performed is a real mid-session
5240            // model change: it moves `Config::model` for every subsequent
5241            // request and is recorded exactly like a user-driven `/model`
5242            // switch (typed record + journal line), never as a silent retry.
5243            for hop in fallback_hops {
5244                self.record_model_change(&hop.from, &hop.to, Some(hop.reason.as_str()));
5245            }
5246            let (mut assistant, usage) = completion?;
5247            // Persist the actual generating model on the message itself.
5248            // A resumed foreign session keeps its original model in
5249            // `SessionMeta`; using only that session-level value on export
5250            // misattributes every Supercode continuation turn to the source
5251            // harness model. Per-message provenance lets native exporters
5252            // preserve the boundary accurately (for example, Claude history
5253            // followed by a GLM continuation).
5254            assistant
5255                .metadata
5256                .insert("model".to_string(), self.config.model.clone());
5257            output_tokens_used += usage.completion_tokens;
5258            self.total_output_tokens += usage.completion_tokens;
5259
5260            // UX-26 (B7-warn): consult the verdict computed at build time
5261            // (before this request was sent) now that `usage` — the only
5262            // piece that couldn't be known pre-send — is in hand. Gated on
5263            // `Config::cache_warnings` (default on; `--no-cache-warnings` /
5264            // `SUPERCODE_CACHE_WARNINGS=0` at the CLI layer, dev/03) so this
5265            // stays a zero-behavior-change no-op for every caller that
5266            // hasn't opted into `CachePlan::ImportedPrefix` in the first
5267            // place (`pending_cache_turn.0` is `false` whenever
5268            // `CachePlan::Off`, so the predicate always returns `None` then
5269            // regardless of this flag).
5270            let (will_annotate, cache_established, idle_secs) = self.pending_cache_turn;
5271            // UX-26 T2 (accuracy fold-in): `CacheColdReason::message` asserts
5272            // Anthropic-specific facts (a fixed 5-minute ephemeral TTL, and
5273            // cache-read-ratio semantics that assume Anthropic's exact-count
5274            // billing) that are only true for Anthropic-family models. This
5275            // is a WARNING-only gate, deliberately not folded into
5276            // `will_annotate`/the breakpoint-placement gate above: whether a
5277            // `cache_control` breakpoint is safe/inert to send to a
5278            // non-Anthropic model through OpenRouter is a separate cache-
5279            // behavior question this ticket doesn't touch (see
5280            // `.volter/tracker/markdown/UX-26.md`'s T2 note) — narrowing only
5281            // the warning keeps this fix scoped to warning ACCURACY, with
5282            // zero change to what gets sent on the wire.
5283            let warning_applies_to_this_model =
5284                provider::is_anthropic_family_model(&self.config.model);
5285            if self.config.cache_warnings && warning_applies_to_this_model {
5286                if let Some(reason) =
5287                    provider::cache_cold_reason(will_annotate, cache_established, idle_secs, &usage)
5288                {
5289                    self.emit(AgentEvent::CacheWarning {
5290                        message: reason.message(),
5291                    });
5292                }
5293            }
5294            // Refresh the activity clock / establish-once flag for the NEXT
5295            // turn's comparison, but only when THIS request actually carried
5296            // the annotation — an unannotated (busted/Off) request neither
5297            // warms nor cools a cache entry it never touched.
5298            if will_annotate {
5299                self.last_cache_activity_ms = Some(now_ms());
5300                self.cache_established = true;
5301            }
5302
5303            // UX-23: emitted before `TurnCompleted` so a `--trace`/
5304            // `stream-json` consumer sees "this round-trip cost N tokens"
5305            // land right alongside the round-trip it describes, rather than
5306            // needing to correlate it with a later event.
5307            self.emit(AgentEvent::Usage(usage.clone()));
5308            self.emit(AgentEvent::TurnCompleted);
5309            // P4b (§1.6, catalog §4a "persisted per-turn usage records"):
5310            // EventSink already streamed `Usage` above — this durably
5311            // accumulates the same data as a typed record (see
5312            // `Self::usage_records`/`Self::save_usage_log`), never a lossy
5313            // display-only channel.
5314            // BP-7 (catalog §4a "Per-turn cost/usage accounting"): the
5315            // record now carries the round-trip's DOLLAR cost too — the
5316            // half the row's semantics name alongside tokens — whenever
5317            // this build can price the model.
5318            // BP-13 (D9 "Model-served-vs-requested provenance"): the record
5319            // now carries BOTH sides — the model this agent asked for and,
5320            // when the provider reported one, the model that actually
5321            // answered. They can genuinely differ (a gateway aliasing a
5322            // name to a dated snapshot, a fallback hop, a routed tier), and
5323            // a record that can only ever state the request cannot show it.
5324            let served = assistant
5325                .metadata
5326                .get(crate::provider::SERVED_MODEL_KEY)
5327                .cloned();
5328            let record = crate::usage_log::UsageRecord::from_usage(
5329                self.turn_index,
5330                &self.config.model,
5331                &usage,
5332                now_ms(),
5333            )
5334            .priced(self.model_price)
5335            .with_served_model(served);
5336            self.total_cost_usd += record.cost_usd.unwrap_or(0.0);
5337            // BP-7: the round-trip's usage bracket, written from the same
5338            // point as the usage record so the two logs never disagree.
5339            self.push_turn_marker_at(
5340                round_trip,
5341                crate::turn_record::TurnMarker::Usage {
5342                    prompt_tokens: record.prompt_tokens,
5343                    completion_tokens: record.completion_tokens,
5344                    total_tokens: record.total_tokens,
5345                    cached_tokens: record.cached_tokens,
5346                    cost_usd: record.cost_usd,
5347                },
5348            );
5349            self.journal_usage(&record);
5350            self.usage_log.push(record);
5351            self.turn_index += 1;
5352            self.record(&assistant)?;
5353            self.history.push(assistant.clone());
5354
5355            let calls = assistant.tool_calls().to_vec();
5356            if calls.is_empty() {
5357                // Close steering acceptance under the same lock as the last
5358                // drain. A message accepted before this boundary extends the
5359                // current turn; anything later is rejected by the SDK and
5360                // can never leak into a future turn.
5361                let (steer_msg, steer_taken) = {
5362                    let mut inbox = self
5363                        .steer_queue
5364                        .lock()
5365                        .unwrap_or_else(std::sync::PoisonError::into_inner);
5366                    let before = inbox.len();
5367                    let drained = inbox.drain_or_close(self.config.steering_mode);
5368                    let taken = before - inbox.len();
5369                    (drained, taken)
5370                };
5371                if let Some(steer_msg) = steer_msg {
5372                    self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
5373                    let msg = ChatMessage::user(steer_msg);
5374                    self.record(&msg)?;
5375                    self.history.push(msg);
5376                    continue;
5377                }
5378                // P4b (§1.7, pi§3 "follow-up = at idle"): a queued follow-up
5379                // message takes priority over the stop-gate — it's more
5380                // input to answer, not a veto of an answer already given.
5381                let follow_up_before = self.follow_up_queue.len();
5382                if let Some(follow_up_msg) =
5383                    Self::drain_steer_queue(&mut self.follow_up_queue, self.config.follow_up_mode)
5384                {
5385                    self.journal_queue_drain(
5386                        crate::session_journal::QueueKind::FollowUp,
5387                        follow_up_before - self.follow_up_queue.len(),
5388                    );
5389                    let msg = ChatMessage::user(follow_up_msg);
5390                    self.record(&msg)?;
5391                    self.history.push(msg);
5392                    continue;
5393                }
5394                // P4b (§1.9/§3.1 `[core] stop_gate`, D3 "stop/completion
5395                // gating"): consulted exactly once per iteration that would
5396                // otherwise return — computed into an owned `Option<String>`
5397                // so the immutable borrow of `self.config.stop_gate` ends
5398                // before the `self.record`/`self.history.push` calls below
5399                // need `&mut self`.
5400                let final_content = assistant.content.clone().unwrap_or_default();
5401                let veto_reason: Option<String> = self
5402                    .config
5403                    .stop_gate
5404                    .as_ref()
5405                    .and_then(|gate| gate(&final_content));
5406                if let Some(reason) = veto_reason {
5407                    let msg = ChatMessage::user(reason);
5408                    self.record(&msg)?;
5409                    self.history.push(msg);
5410                    continue;
5411                }
5412                // BP-8 (catalog:156): the last iteration's tool calls are
5413                // the ones the top-of-loop flush above never sees.
5414                self.journal_plan_if_changed();
5415                self.push_turn_marker_at(
5416                    round_trip,
5417                    crate::turn_record::TurnMarker::Finish {
5418                        reason: crate::turn_record::FinishReason::EndTurn,
5419                    },
5420                );
5421                return Ok(assistant.content.unwrap_or_default());
5422            }
5423
5424            // BP-7: this round-trip ended by asking for tool calls; the
5425            // loop continues. The budget arms below mark the LOOP's end
5426            // separately when one of them stops it here.
5427            self.push_turn_marker_at(
5428                round_trip,
5429                crate::turn_record::TurnMarker::Finish {
5430                    reason: crate::turn_record::FinishReason::ToolCalls,
5431                },
5432            );
5433
5434            // Output-token budget (output only — input tokens are not counted,
5435            // so this does not bound cost): stop spawning further model turns
5436            // once the cumulative output-token budget for this `send` is
5437            // exhausted.
5438            if let Some(budget) = self.config.max_total_output_tokens {
5439                if output_tokens_used >= budget {
5440                    // The assistant turn we just pushed carries unanswered
5441                    // tool_calls. Leaving them dangling yields an invalid
5442                    // history (assistant tool_calls with no tool results) that
5443                    // the provider rejects on the next `send`/resume. Emit
5444                    // synthetic results so the transcript stays well-formed.
5445                    for call in &calls {
5446                        let msg = ChatMessage::tool_result(
5447                            call.id.clone(),
5448                            call.function.name.clone(),
5449                            "[skipped: output token budget reached]".to_string(),
5450                        );
5451                        self.record(&msg)?;
5452                        self.history.push(msg);
5453                    }
5454                    self.push_turn_marker_at(
5455                        round_trip,
5456                        crate::turn_record::TurnMarker::Finish {
5457                            reason: crate::turn_record::FinishReason::OutputTokenBudget,
5458                        },
5459                    );
5460                    return Ok(assistant.content.clone().unwrap_or_default());
5461                }
5462            }
5463
5464            // BP-7 (catalog §4a "Turn/budget caps"): the SPEND and STEP
5465            // caps, at the same point and with the same shape as the
5466            // output-token cap above — checked before this turn's tool
5467            // calls run, with synthetic results so the transcript stays
5468            // well-formed for a resume.
5469            let spend_exhausted = self
5470                .config
5471                .max_budget_usd
5472                .is_some_and(|b| b > 0.0 && self.total_cost_usd >= b);
5473            let steps_exhausted = self
5474                .config
5475                .max_steps
5476                .is_some_and(|n| n > 0 && self.total_steps + calls.len() > n);
5477            if spend_exhausted || steps_exhausted {
5478                let (label, reason) = if spend_exhausted {
5479                    (
5480                        "[skipped: spend budget reached]",
5481                        crate::turn_record::FinishReason::SpendBudget,
5482                    )
5483                } else {
5484                    (
5485                        "[skipped: step budget reached]",
5486                        crate::turn_record::FinishReason::StepBudget,
5487                    )
5488                };
5489                for call in &calls {
5490                    let msg = ChatMessage::tool_result(
5491                        call.id.clone(),
5492                        call.function.name.clone(),
5493                        label.to_string(),
5494                    );
5495                    self.record(&msg)?;
5496                    self.history.push(msg);
5497                }
5498                self.push_turn_marker_at(
5499                    round_trip,
5500                    crate::turn_record::TurnMarker::Finish { reason },
5501                );
5502                return Ok(assistant.content.clone().unwrap_or_default());
5503            }
5504            self.total_steps += calls.len();
5505
5506            // P4e (§3.1 `core.parallel_tool_calls`, catalog:59): off (the
5507            // default) or a single call takes the EXACT pre-P4e sequential
5508            // path below, byte-identical. Only `true` with 2+ calls in this
5509            // turn takes `Self::run_tools_concurrently` — see its doc
5510            // comment for exactly what does and doesn't run concurrently.
5511            if self.config.parallel_tool_calls && calls.len() > 1 {
5512                for call in &calls {
5513                    self.emit(AgentEvent::tool_started(call));
5514                }
5515                let results = self.run_tools_concurrently(&calls).await;
5516                for (call, (output, is_error)) in calls.iter().zip(results) {
5517                    self.emit(AgentEvent::ToolCallCompleted {
5518                        id: call.id.clone(),
5519                        name: call.function.name.clone(),
5520                        output: output.clone(),
5521                        is_error,
5522                    });
5523                    self.apply_tool_result(call, output, is_error)?;
5524                }
5525            } else {
5526                for call in &calls {
5527                    self.emit(AgentEvent::tool_started(call));
5528                    let (output, is_error) = self.run_tool(call).await;
5529                    self.emit(AgentEvent::ToolCallCompleted {
5530                        id: call.id.clone(),
5531                        name: call.function.name.clone(),
5532                        output: output.clone(),
5533                        is_error,
5534                    });
5535                    self.apply_tool_result(call, output, is_error)?;
5536                }
5537            }
5538            // BP-3 (catalog row "Context-budget tools"): a `new_context`
5539            // call parks its request on the shared budget; this is where
5540            // the agent — the one owner of `history` — applies it, so the
5541            // NEXT request built by this loop is already the fresh window.
5542            // No parked request (every session that never calls the tool)
5543            // is a single `Option` check.
5544            self.apply_pending_new_context();
5545        }
5546
5547        self.push_turn_marker_at(
5548            self.turn_index.saturating_sub(1),
5549            crate::turn_record::TurnMarker::Finish {
5550                reason: crate::turn_record::FinishReason::MaxIterations,
5551            },
5552        );
5553        Err(Error::MaxIterations(self.config.max_iterations))
5554    }
5555
5556    /// BP-3: apply a parked [`crate::tools::NewContextRequest`], if any.
5557    ///
5558    /// The rewrite itself is BP-4's [`Self::new_context`] — the SAME
5559    /// mechanism the operator's `/handoff` runs, so the model's door and the
5560    /// human's door can never drift into two different notions of "a fresh
5561    /// window". This function is only the hand-off point between the tool
5562    /// that asked and the agent that owns `history`.
5563    fn apply_pending_new_context(&mut self) {
5564        let Some(request) = self.ctx.context_budget.take_new_context() else {
5565            return;
5566        };
5567        self.new_context(&request.objective, request.keep_recent);
5568    }
5569
5570    /// The exact post-execution handling every tool result gets, regardless
5571    /// of whether it was produced by the sequential loop or
5572    /// [`Self::run_tools_concurrently`] — factored out of `Self::run_loop`'s
5573    /// tool-dispatch section (P4e) so both paths share one copy: multimodal
5574    /// image-marker detection, A7 output capping (gated exactly as before),
5575    /// TR-10 error stamping, and the `record`/`history` append. Always
5576    /// called in ORIGINAL call order, one call at a time, so the lossless
5577    /// sidecar's append-order invariant (S1.13) holds regardless of which
5578    /// dispatch path produced the result.
5579    fn apply_tool_result(
5580        &mut self,
5581        call: &crate::message::ToolCall,
5582        output: String,
5583        is_error: bool,
5584    ) -> Result<()> {
5585        // P4c (§1.2 `core.tools.read_file.multimodal` / `view_image`):
5586        // a successful tool result carrying the image-data-URL
5587        // marker becomes a `content_parts` image block instead of
5588        // plain text — checked BEFORE `cap_tool_output` (a data URL
5589        // is not meaningfully "capped" by a byte-length text notice)
5590        // and recorded identically on both the full and history
5591        // copies, mirroring `ImageRedacted`'s "images are their own
5592        // axis, orthogonal to A7 text truncation" treatment
5593        // (reduce.rs). An ERRORED call never carries the marker (a
5594        // tool only emits it on success), so `is_error` is not
5595        // re-checked here.
5596        if let Some(data_url) = output.strip_prefix(crate::tools::MULTIMODAL_IMAGE_MARKER) {
5597            let notice = format!("[{}: image content attached below]", call.function.name);
5598            let full_result = ChatMessage::tool_result_with_image(
5599                call.id.clone(),
5600                call.function.name.clone(),
5601                notice.clone(),
5602                data_url.to_string(),
5603            );
5604            let hist_result = ChatMessage::tool_result_with_image(
5605                call.id.clone(),
5606                call.function.name.clone(),
5607                notice,
5608                data_url.to_string(),
5609            );
5610            self.record(&full_result)?;
5611            self.history.push(hist_result);
5612            return Ok(());
5613        }
5614        // Record the FULL output before capping (A3): what the
5615        // sidecar keeps must never be the already-lossy, truncated
5616        // copy (#8/#40) — `history` alone governs what shrinks.
5617        let mut full_result =
5618            ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), output.clone());
5619        // D6/A7 supersession gate (TR-12 land-blocker fix): `history`
5620        // is the exact slice `reduce::project_messages` mints A7/A10
5621        // reduction hashes from (`Self::build_request_messages`
5622        // below). Capping it here — as this unconditionally used to
5623        // do — would silently shrink the bytes those hashes cover, so
5624        // a hash minted now could never recompute the same way once
5625        // the sidecar is reloaded from disk later (`verify_log`/
5626        // `invert`, offline). Gate `cap_tool_output` off in exactly
5627        // the combination where reductions can be minted over
5628        // `history` AND the full bytes are durably retained: a
5629        // recorder AND a `ReductionPolicy` both installed. A7 then
5630        // owns tool-output bounding, reversibly, at projection time
5631        // (SPEC.md D6/A7) — `history`/the sidecar keep everything,
5632        // only the request view shrinks. With a policy but no
5633        // recorder (constructible via `set_reduction_policy` alone),
5634        // nothing durable backs the full bytes, so capping stays on —
5635        // the same honest-labeling spirit as `cap_tool_output`'s own
5636        // retention branch below, just applied at the gate instead of
5637        // the notice text. With no policy at all, this is untouched:
5638        // today's byte-identical legacy cap.
5639        let for_history = if self.recorder.is_some() && self.reduction_policy.is_some() {
5640            output
5641        } else {
5642            self.cap_tool_output(output)
5643        };
5644        let mut hist_result =
5645            ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), for_history);
5646        if is_error {
5647            // TR-10: the reduction layer's success/failure boundary
5648            // (`ReductionKind::ToolInputElided` must never target an
5649            // errored call — TR-6's territory) has no other
5650            // structural signal on `ChatMessage`; stamp both the
5651            // recorded copy (so it survives a sidecar round-trip via
5652            // `NativeTurn`) and the live-history copy (so an
5653            // in-process `project_messages` sees it immediately).
5654            reduce::mark_tool_error(&mut full_result);
5655            reduce::mark_tool_error(&mut hist_result);
5656        }
5657        self.record(&full_result)?;
5658        self.history.push(hist_result);
5659        Ok(())
5660    }
5661
5662    /// Truncate an oversized tool result so a single runaway command can't blow
5663    /// up the context window. Cuts on a char boundary and appends a notice.
5664    fn cap_tool_output(&self, output: String) -> String {
5665        let Some(max) = self.config.max_tool_output_bytes else {
5666            return output;
5667        };
5668        if max == 0 || output.len() <= max {
5669            return output;
5670        }
5671        // Find the largest char boundary <= max.
5672        let mut end = max;
5673        while end > 0 && !output.is_char_boundary(end) {
5674            end -= 1;
5675        }
5676        let total = output.len();
5677        let mut s = output[..end].to_string();
5678        // BP-2 (catalog:58, `core.tool_output_spill`): write the full bytes
5679        // to a per-session file the model can read back. Off (the default)
5680        // leaves the notice byte-identical to before.
5681        let spill = if self.config.tool_output_spill {
5682            self.spill_tool_output(&output)
5683        } else {
5684            None
5685        };
5686        // Honest retention labeling (D6, B10-AC4): only claim the sidecar has
5687        // the full output when a recorder is actually installed — or, BP-2,
5688        // that the spill file has it when one was actually written.
5689        let retention = if self.recorder.is_some() {
5690            "full output in session sidecar"
5691        } else if spill.is_some() {
5692            "full output on disk"
5693        } else {
5694            "full output not retained"
5695        };
5696        let recovery = match &spill {
5697            // The door is named in the notice, so it works WITHOUT
5698            // `capabilities.reduction`: under a preset with a read tool
5699            // that is `read_file`; under a shell-only preset (cx-parity,
5700            // whose whole read pathway is the shell) it is `cat`.
5701            Some(path) => {
5702                let door = if self.registry.get("read_file").is_some() {
5703                    "read it with `read_file`"
5704                } else {
5705                    "read it with `cat`"
5706                };
5707                format!("; full output spilled to {} — {door}", path.display())
5708            }
5709            None => String::new(),
5710        };
5711        s.push_str(&format!(
5712            "{CAP_NOTICE_MARKER}{total} bytes total, showing first {end}; {retention}{recovery}]"
5713        ));
5714        s
5715    }
5716
5717    /// BP-2 (catalog:58 "Oversized output truncated; full content kept
5718    /// reachable"): write `full` to this session's spill directory and
5719    /// return the path, or `None` if it could not be written (a spill is a
5720    /// recovery convenience — it must never fail the tool call).
5721    ///
5722    /// The file is named by content hash, so the same output spilled twice
5723    /// costs one file and a re-run of an identical command reuses it.
5724    fn spill_tool_output(&self, full: &str) -> Option<std::path::PathBuf> {
5725        let dir = self.spill_dir();
5726        std::fs::create_dir_all(&dir).ok()?;
5727        let digest = blake3::hash(full.as_bytes()).to_hex();
5728        let path = dir.join(format!("tool-output-{}.txt", &digest[..16]));
5729        if !path.exists() {
5730            std::fs::write(&path, full).ok()?;
5731        }
5732        Some(path)
5733    }
5734
5735    /// BP-2: where this agent's spilled outputs live — beside the session
5736    /// sidecar when one is recording (per-SESSION, the same identity the
5737    /// sidecar has), else a per-PROCESS temp directory, which is as
5738    /// specific as an agent with no sidecar can honestly be.
5739    fn spill_dir(&self) -> std::path::PathBuf {
5740        if let Some(recorder) = &self.recorder {
5741            let path = recorder.path();
5742            if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
5743                return parent.join(format!("{}.spill", stem.to_string_lossy()));
5744            }
5745        }
5746        std::env::temp_dir().join(format!("supercode-spill-{}", std::process::id()))
5747    }
5748
5749    /// P5-3 note on the signature: written as a plain fn returning an
5750    /// explicitly boxed future (`Pin<Box<dyn Future + Send>>`) rather than
5751    /// as `async fn`. `spawn_subagent` makes this function genuinely
5752    /// recursive at the TYPE level: `run_tool` -> `run_spawn_subagent` ->
5753    /// (a child) `Agent::send` -> `run_loop` -> `run_tool` again — an
5754    /// `async fn`'s return type is an anonymous, compiler-inferred
5755    /// self-referential state machine, and inferring one that embeds
5756    /// itself (even indirectly, through several other functions) is a
5757    /// compile error (an infinitely-sized/cyclic opaque type). Declaring
5758    /// `run_tool`'s return type EXPLICITLY as a boxed trait object breaks
5759    /// the cycle: every other function on the call graph now embeds a
5760    /// concrete, already-known type here instead of one the compiler would
5761    /// otherwise need to (cyclically) infer. Callers are unaffected —
5762    /// `self.run_tool(call).await` reads identically either way.
5763    fn run_tool<'a>(
5764        &'a mut self,
5765        call: &'a crate::message::ToolCall,
5766    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send + 'a>> {
5767        Box::pin(async move {
5768            let translated_builtin = if self.config.claude_runtime_tools_enabled {
5769                match self.translate_claude_builtin_call(call) {
5770                    Ok(translated) => translated,
5771                    Err(error) => return (format!("Error: {error}"), true),
5772                }
5773            } else {
5774                None
5775            };
5776            let call = translated_builtin.as_ref().unwrap_or(call);
5777            if self.config.claude_runtime_tools_enabled
5778                && matches!(
5779                    call.function.name.as_str(),
5780                    CLAUDE_CRON_CREATE
5781                        | CLAUDE_CRON_DELETE
5782                        | CLAUDE_CRON_LIST
5783                        | CLAUDE_SCHEDULE_WAKEUP
5784                )
5785            {
5786                return self.run_claude_runtime_tool(call);
5787            }
5788            // P5-3: `spawn_subagent`/`subagent_status` need full async
5789            // `&mut self` access (running a child agent's loop, or
5790            // awaiting an already-finished background `JoinHandle`) —
5791            // `prepare_tool_call` is purely synchronous, so these are
5792            // intercepted HERE, one level above it, rather than inside it
5793            // like `TOOL_SEARCH`/`EXPAND_REDUCTION`/`SIDECAR_SEARCH`.
5794            if call.function.name == CLAUDE_AGENT && self.config.subagents_claude_agent_alias {
5795                return match self.translate_claude_agent_call(call) {
5796                    Ok(translated) => self.run_spawn_subagent(&translated).await,
5797                    Err(error) => (format!("Error: {error}"), true),
5798                };
5799            }
5800            if call.function.name == SPAWN_SUBAGENT {
5801                return self.run_spawn_subagent(call).await;
5802            }
5803            // P5-3 safety-hardening fix (Fable-5 review, LOW "wrong error
5804            // when disabled"): gated on `subagents_enabled`, matching
5805            // `run_spawn_subagent`'s own already-correct disabled behavior
5806            // (that one gates INTERNALLY, at its own top; this one gates
5807            // HERE, at the interception point, because unlike
5808            // `spawn_subagent` it has no other reason to run any logic at
5809            // all when subagents are off). When disabled, a hallucinated
5810            // `subagent_status` call must NOT be intercepted — it falls
5811            // through to `prepare_tool_call`'s normal unknown-tool path
5812            // below, which returns `Error::UnknownTool("subagent_status")`,
5813            // byte-identical to the pre-P5-3 (and disabled-spawn_subagent)
5814            // error text — never `Error::SubagentNotFound`'s "unknown
5815            // subagent id" text, which would wrongly imply subagents are on
5816            // but this particular id is bogus.
5817            if call.function.name == SUBAGENT_STATUS && self.config.subagents_enabled {
5818                return self.run_subagent_status(call).await;
5819            }
5820            // BP-7: same interception shape and same `subagents_enabled`
5821            // gate as `SUBAGENT_STATUS` above — when the module is off a
5822            // hallucinated call falls through to the ordinary unknown-tool
5823            // error rather than a misleading "unknown subagent id".
5824            if call.function.name == SUBAGENT_MESSAGE && self.config.subagents_enabled {
5825                return self.run_subagent_message(call);
5826            }
5827            if call.function.name == SUBAGENT_RESUME && self.config.subagents_enabled {
5828                return self.run_subagent_resume(call).await;
5829            }
5830            match self.prepare_tool_call(call) {
5831                PreparedCall::Done(result) => result,
5832                PreparedCall::Ready { name, args } => {
5833                    // `prepare_tool_call` already confirmed the registry has
5834                    // this tool.
5835                    let tool = self.registry.get(&name).expect("prepared as Ready");
5836                    let (output, is_error) = match tool.execute(args, &self.ctx).await {
5837                        Ok(out) => (out, false),
5838                        Err(e) => (format!("Error: {e}"), true),
5839                    };
5840                    if let Some(hook) = &self.config.post_tool_hook {
5841                        hook(&name, &output, is_error);
5842                    }
5843                    (output, is_error)
5844                }
5845            }
5846        })
5847    }
5848
5849    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59): the SYNCHRONOUS
5850    /// half of dispatching one tool call — everything `Self::run_tool` did
5851    /// BEFORE its single `tool.execute(...).await`, factored out so
5852    /// [`Self::run_tools_concurrently`] can run these cheap, stateful,
5853    /// `&mut self` checks (agent intrinsics, unknown-tool, approval,
5854    /// doom-loop, pre-tool-hook) SEQUENTIALLY and in ORIGINAL call order —
5855    /// exactly as `run_tool` always has — before handing the remaining
5856    /// calls' `execute()` futures to `join_all`. `Self::run_tool` itself is
5857    /// now a thin wrapper over this (a pure refactor: byte-identical
5858    /// observable behavior, verified by the existing test suite).
5859    fn prepare_tool_call(&mut self, call: &crate::message::ToolCall) -> PreparedCall {
5860        let name = &call.function.name;
5861        // BP-3 (catalog row "Context-budget tools"): hand the model's
5862        // `get_context_remaining` the agent's OWN accounting — BP-4's
5863        // [`Self::context_usage`], the same struct `/context` prints and
5864        // the same estimates the context guard enforces, so what the model
5865        // reads and what refuses an oversized turn can never disagree.
5866        // Computed at the moment the question is asked (the freshest
5867        // possible view) and ONLY then: `context_usage` projects the whole
5868        // request view, which is not a cost to pay on unrelated calls.
5869        if name == crate::tools::GET_CONTEXT_REMAINING {
5870            if let Ok(usage) = serde_json::to_value(self.context_usage()) {
5871                self.ctx.context_budget.publish(usage);
5872            }
5873        }
5874        if name == TOOL_SEARCH {
5875            // Agent intrinsic (B6): intercepted before registry lookup, since
5876            // `Tool::execute` has no access to the registry or `activated_tools`.
5877            return PreparedCall::Done(self.run_tool_search(call));
5878        }
5879        if name == EXPAND_REDUCTION {
5880            // Agent intrinsic (T12/TR-1): intercepted before registry lookup,
5881            // same reason — resolves against `self.reduction_log`/`self.history`,
5882            // which `Tool::execute` has no access to.
5883            return PreparedCall::Done(self.run_expand_reduction(call));
5884        }
5885        if name == SIDECAR_SEARCH {
5886            return PreparedCall::Done(self.run_sidecar_search(call));
5887        }
5888        // P5-6 (§2 module 4 `tools.background`): gated at the interception
5889        // point itself (not internally, at each method's own top) —
5890        // mirroring `SUBAGENT_STATUS`'s own fix (Fable-5 review, LOW "wrong
5891        // error when disabled"): a hallucinated call when the module is off
5892        // must fall through to the plain `Error::UnknownTool` path below,
5893        // never a background-specific error that would wrongly imply the
5894        // module is on. Unlike `SPAWN_SUBAGENT`/`SUBAGENT_STATUS`, none of
5895        // these four need async `&mut self` access (spawning a process,
5896        // `Child::try_wait`, and `Child::start_kill` are all synchronous),
5897        // so they're intercepted here in `prepare_tool_call` rather than in
5898        // `Self::run_tool`.
5899        if self.config.tools_background_enabled {
5900            if name == BACKGROUND_EXEC {
5901                return PreparedCall::Done(self.run_background_exec(call));
5902            }
5903            if name == BACKGROUND_STATUS {
5904                return PreparedCall::Done(self.run_background_status(call));
5905            }
5906            if name == BACKGROUND_LIST {
5907                return PreparedCall::Done(self.run_background_list(call));
5908            }
5909            if name == BACKGROUND_KILL {
5910                return PreparedCall::Done(self.run_background_kill(call));
5911            }
5912        }
5913        if self.registry.get(name).is_none() {
5914            let err = Error::UnknownTool(name.clone());
5915            return PreparedCall::Done((format!("Error: {err}"), true));
5916        }
5917
5918        // P5-1 (§2 modules 10-11, integration point named in
5919        // COMPOSABLE-HARNESS-DESIGN.md's activation set): the permissions
5920        // ENGINE governs the gate when `capabilities.permissions.enabled`
5921        // is on; every other config resolves this to `false`
5922        // (`Config::default`), which takes the `else` branch below —
5923        // the EXACT pre-P5-1 code, untouched, so the default posture
5924        // (approval=never/sandbox=none) and every existing test's observed
5925        // behavior is byte-for-byte unchanged.
5926        if self.config.permissions_enabled {
5927            // The engine needs the command/path TEXT the legacy tool-name-
5928            // only gate below never looked at, so args must be parsed
5929            // BEFORE the gate here (not after, like the legacy branch).
5930            let args = match call.function.parsed_arguments() {
5931                Ok(v) => v,
5932                Err(e) => {
5933                    let err = Error::InvalidArguments {
5934                        tool: name.clone(),
5935                        message: e.to_string(),
5936                    };
5937                    return PreparedCall::Done((format!("Error: {err}"), true));
5938                }
5939            };
5940            // P4c doom-loop breaker, unchanged, still before any gate.
5941            if let Some(reason) = self.check_doom_loop(name, &args) {
5942                return PreparedCall::Done((format!("Error: {reason}"), true));
5943            }
5944            // BP-10: the hook runs BEFORE the engine on this path, so its
5945            // rewrite is what the rules see and its allow/ask/deny is a
5946            // tier inside them — see `run_pre_tool_hook`.
5947            let (args, hook_decision) = match self.run_pre_tool_hook(name, args) {
5948                Ok(pair) => pair,
5949                Err(done) => return done,
5950            };
5951            if let Some(reason) = self.permissions_gate_denial(name, &args, hook_decision) {
5952                return PreparedCall::Done((format!("Error: {reason}"), true));
5953            }
5954            PreparedCall::Ready {
5955                name: name.clone(),
5956                args,
5957            }
5958        } else {
5959            // ---- pre-P5-1 gate, byte-for-byte unchanged ----
5960            // Approval gate: if the policy requires it, consult the handler
5961            // (absent handler denies, so an OnRequest/Untrusted policy is
5962            // fail-closed).
5963            if self.config.needs_approval(name) {
5964                let approved = self
5965                    .config
5966                    .approval_handler
5967                    .as_ref()
5968                    .map(|h| h(call))
5969                    .unwrap_or(false);
5970                if !approved {
5971                    return PreparedCall::Done((
5972                        format!("Error: tool `{name}` was not approved for execution"),
5973                        true,
5974                    ));
5975                }
5976            }
5977            let args = match call.function.parsed_arguments() {
5978                Ok(v) => v,
5979                Err(e) => {
5980                    let err = Error::InvalidArguments {
5981                        tool: name.clone(),
5982                        message: e.to_string(),
5983                    };
5984                    return PreparedCall::Done((format!("Error: {err}"), true));
5985                }
5986            };
5987            self.finish_prepare(name.clone(), args)
5988        }
5989    }
5990
5991    /// P5-1: the shared tail of [`Self::prepare_tool_call`] — doom-loop
5992    /// check, pre-tool hook, `Ready` construction — factored out so both
5993    /// the legacy gate and the new permissions-engine gate run the exact
5994    /// same downstream checks in the exact same order (§5.3 risk 1: the
5995    /// permissions engine changes WHO gets to run, never what happens once
5996    /// they're approved).
5997    fn finish_prepare(&mut self, name: String, args: serde_json::Value) -> PreparedCall {
5998        // P4c (§5.2 P4 "doom-loop breaker", oc UNIQUE `doom_loop` row,
5999        // catalog D3): a default, always-available veto point distinct from
6000        // `Config.pre_tool_hook` (a single user-installable slot — the
6001        // breaker must coexist with a caller's own hook, not compete for the
6002        // one slot). `None`/`Some(0|1)` is a no-op — byte-identical to
6003        // today (no repetition tracking, no call is ever refused on this
6004        // basis).
6005        if let Some(reason) = self.check_doom_loop(&name, &args) {
6006            return PreparedCall::Done((format!("Error: {reason}"), true));
6007        }
6008        // Pre-tool hook may block the call. BP-10: the pre-P5-1 gate has
6009        // no permissions engine for a hook's `Allow`/`Ask` to be a tier
6010        // OF, so only the deny half can mean anything here — an
6011        // `updated_args` rewrite still applies (it is a property of the
6012        // call, not of any gate), and `Allow`/`Ask` are no-ops, exactly
6013        // as `None` was before BP-10.
6014        let mut args = args;
6015        if let Some(hook) = &self.config.pre_tool_hook {
6016            let outcome = hook(&name, &args);
6017            if let Some(rewritten) = outcome.updated_args {
6018                args = rewritten;
6019            }
6020            if outcome.decision == crate::config::HookDecision::Deny {
6021                let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
6022                return PreparedCall::Done((
6023                    format!("Error: blocked by pre-tool hook: {reason}"),
6024                    true,
6025                ));
6026            }
6027        }
6028        PreparedCall::Ready { name, args }
6029    }
6030
6031    /// BP-10 (catalog row "Hook/plugin permission veto"): fire the pre-tool
6032    /// hook for the permissions-engine path, where it runs BEFORE the gate
6033    /// (CC's own order: a `PreToolUse` hook answers the permission question
6034    /// rather than being asked after it). Returns the possibly-REWRITTEN
6035    /// arguments plus the [`crate::config::HookDecision`] the engine folds
6036    /// in, or the finished denial when the hook refused outright.
6037    ///
6038    /// The rewrite lands BEFORE the gate deliberately: the engine must
6039    /// evaluate what will actually run, so a hook cannot launder a denied
6040    /// command by rewriting it past the rules.
6041    #[allow(clippy::type_complexity)]
6042    fn run_pre_tool_hook(
6043        &self,
6044        name: &str,
6045        args: serde_json::Value,
6046    ) -> std::result::Result<(serde_json::Value, crate::config::HookDecision), PreparedCall> {
6047        let Some(hook) = &self.config.pre_tool_hook else {
6048            return Ok((args, crate::config::HookDecision::Pass));
6049        };
6050        let outcome = hook(name, &args);
6051        let args = outcome.updated_args.unwrap_or(args);
6052        if outcome.decision == crate::config::HookDecision::Deny {
6053            let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
6054            return Err(PreparedCall::Done((
6055                format!("Error: blocked by pre-tool hook: {reason}"),
6056                true,
6057            )));
6058        }
6059        Ok((args, outcome.decision))
6060    }
6061
6062    /// D-2 (Fable-5 delta review — LOW-MEDIUM, "over-grant residual"): the
6063    /// built-in tools whose `command`/`path`/`patch` arg IS semantically
6064    /// the whole call — the ONLY tools [`Self::permissions_gate_denial`]
6065    /// is allowed to turn into an [`permissions::ApprovalRequest::subject`]
6066    /// (see that method's own doc comment on the `subject` line for the
6067    /// full story). Bash-family (`bash`, and `shell` —
6068    /// [`crate::tools::builtins::PersistentShellTool`]'s registered name,
6069    /// what the review's "persistent-shell" refers to), the file tools
6070    /// (`read_file`/`write_file`/`edit_file`/`view_image`, whose `path` IS
6071    /// the subject), and `apply_patch` (whose `patch` envelope is handled
6072    /// separately but is unconditionally this tool only, see the `patch`
6073    /// local a few lines below). Deliberately NOT `list_dir`/`glob`/
6074    /// `search` — this crate's F2 fix (`ApprovalCache::key_for_request`)
6075    /// already falls back to a full-args digest for anything not on this
6076    /// list, which is a strictly SAFER (if slightly less cache-granular)
6077    /// default than guessing at more built-ins that weren't part of this
6078    /// finding.
6079    const SUBJECT_BEARING_BUILTIN_TOOLS: &'static [&'static str] = &[
6080        "bash",
6081        "shell",
6082        "read_file",
6083        "write_file",
6084        "edit_file",
6085        "view_image",
6086        "apply_patch",
6087    ];
6088
6089    /// P5-1: evaluate `name`'s call (with parsed `args`) against the
6090    /// permissions engine (`crate::permissions`) — builds the
6091    /// [`crate::permissions::RuleSet`] from `Config`'s deny/ask/allow
6092    /// pattern lists (folding [`Config::permissions_protected_paths`] into
6093    /// the `deny` tier, module 13), picks a command-, path-, or name-only
6094    /// evaluation depending on what `args` carries, resolves an `Ask`
6095    /// decision via the session cache + THIS agent's own installed
6096    /// [`crate::permissions::PermissionsApprovalHandler`], and returns
6097    /// `Some(reason)` when the call is refused (`None` = proceed). Thin
6098    /// wrapper over [`Self::permissions_gate_denial_impl`] — see that
6099    /// method's doc comment for why the handler is a parameter there.
6100    fn permissions_gate_denial(
6101        &self,
6102        name: &str,
6103        args: &serde_json::Value,
6104        hook: crate::config::HookDecision,
6105    ) -> Option<String> {
6106        self.permissions_gate_denial_impl(
6107            name,
6108            args,
6109            self.permissions_approval_handler.as_deref(),
6110            hook,
6111        )
6112    }
6113
6114    /// P5-6 (§2.2 C6, build brief "wire to the P5-1 engine's non-
6115    /// interactive fail-closed path"): the SAME rule-evaluation body as
6116    /// [`Self::permissions_gate_denial`], but the approval `handler` is a
6117    /// PARAMETER instead of always reading `self.permissions_approval_handler`
6118    /// — `Agent::background_permission_denial` calls this with `handler:
6119    /// None` (or a [`crate::subagents::ParentQueueApprovalHandler`]) so a
6120    /// `background_exec` call's `Ask`-tier decisions resolve exactly like a
6121    /// P5-3 background child's do (`crate::permissions::resolve_ask`'s
6122    /// pre-existing "no handler ⇒ deny" contract), REGARDLESS of whether
6123    /// this agent itself has an interactive handler installed for its own
6124    /// foreground calls — a background job must never block on a prompt it
6125    /// has no way to answer, even if the agent hosting it could otherwise
6126    /// answer one. The rule SET and default-policy baseline are otherwise
6127    /// identical to a foreground call's — only how an `Ask` decision
6128    /// resolves ever differs, and only in the strictly-narrower direction
6129    /// (never escalates past what a foreground call of the same command is
6130    /// allowed).
6131    fn permissions_gate_denial_impl(
6132        &self,
6133        name: &str,
6134        args: &serde_json::Value,
6135        handler: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
6136        hook: crate::config::HookDecision,
6137    ) -> Option<String> {
6138        use crate::permissions::{self, Decision, PathKind};
6139
6140        // `Config.tool_deny_patterns`/`tool_allow_patterns` (P4a) ARE the
6141        // engine's deny/allow tiers — the same `capabilities.permissions.
6142        // rules.deny`/`.allow` keys, one source of truth, no duplication.
6143        // Protected paths (module 13) are an unconditional deny floor,
6144        // folded in here rather than checked separately, so they benefit
6145        // from the SAME first-match deny-wins priority every other deny
6146        // rule gets.
6147        // BP-5: the rule set itself is `permissions::rules_for_config` —
6148        // ONE construction shared with every other surface that has to ask
6149        // this engine a question (see that function's doc comment). Plan
6150        // mode's narrowing is layered on top of it here, because it is
6151        // this agent's live state, not the config's.
6152        let mut rules = permissions::rules_for_config(&self.config);
6153        // BP-3 (§2 module 8 `plan_mode`, dependency edge `plan_mode →
6154        // permissions.rules|sandbox`): the read-only research phase IS a
6155        // narrowing of this rule set — while the mode is active the write
6156        // and execution tools join the deny tier, and get the same
6157        // first-match, never-overridable treatment every other deny rule
6158        // gets. Inactive (the default, and the only state a config without
6159        // the module can reach) contributes NOTHING, so the rule set is
6160        // byte-identical to before.
6161        rules
6162            .deny
6163            .extend(crate::tools::plan_mode::deny_rules(&self.ctx.plan_mode));
6164
6165        // The baseline decision when NO rule matches at all — derived from
6166        // `ApprovalPolicy`, the same per-policy shape
6167        // `Config::needs_approval` uses for the legacy gate (see
6168        // `ApprovalPolicy::ModelRequested`'s doc comment for why this
6169        // richer gate approximates Codex's real "mostly silent" posture
6170        // instead of that method's conservative OnRequest-alike treatment
6171        // — explicit deny/ask rules still apply on top regardless).
6172        let default = permissions::default_decision(&self.config, name);
6173
6174        // BP-10: path rules are evaluated relative to EVERY granted root
6175        // (cwd + `core.additional_dirs`/`--add-dir`), folded to the
6176        // strictest — see `permissions::evaluate_path_safe_roots`'s doc
6177        // comment for why a grant must not also remove the root-relative
6178        // protected-path floor inside the granted directory. With no extra
6179        // dirs (the default) this is the single `cwd` list, byte-identical
6180        // to before.
6181        let mut roots = vec![self.config.cwd.clone()];
6182        roots.extend(self.config.additional_dirs.iter().cloned());
6183
6184        let command = args.get("command").and_then(|v| v.as_str());
6185        let path = args.get("path").and_then(|v| v.as_str());
6186        // F4 (Fable-5 adversarial review): `apply_patch`'s args carry a
6187        // patch ENVELOPE body (`args["patch"]`), not a `command` or a
6188        // `path` — the two branches above never fire for it, which is
6189        // exactly how a patch touching a protected path bypassed
6190        // `protected_paths` entirely. Only consulted for the `apply_patch`
6191        // tool specifically (a `patch`-shaped arg on some other tool is not
6192        // this envelope format and isn't given this treatment).
6193        let patch = (name == "apply_patch")
6194            .then(|| args.get("patch").and_then(|v| v.as_str()))
6195            .flatten();
6196        let decision = if let Some(command) = command {
6197            permissions::evaluate_command(&rules, name, command, default)
6198        } else if let Some(patch) = patch {
6199            // Same dual-check shape as the path branch below (pseudo-tool
6200            // `write(...)` rules from `protected_paths`, AND a rule
6201            // authored against the real `apply_patch` tool name), applied
6202            // to EVERY path the envelope's ops touch (`Add`/`Delete`/
6203            // `Update`'s `path`, plus `*** Move to:`). A patch that fails
6204            // to parse can't be proven to avoid a protected path — fail
6205            // closed to at least `Ask`, the same floor an unparseable bash
6206            // command gets in `permissions::evaluate_command`, rather than
6207            // silently let it through on `default`.
6208            let mut d = rules.evaluate(name, None).unwrap_or(default);
6209            match crate::tools::patch_target_paths(patch) {
6210                Ok(paths) => {
6211                    for p in &paths {
6212                        // SECURITY (CRITICAL fix): route both checks through
6213                        // the safe-path-resolving variants — a patch target
6214                        // like `x/../.git/config` must be caught exactly
6215                        // like a `write_file`/`edit_file` `path` argument
6216                        // would be (see `evaluate_path_safe`'s doc comment).
6217                        let pseudo = permissions::evaluate_path_safe_roots(
6218                            &rules,
6219                            PathKind::Write,
6220                            &roots,
6221                            p,
6222                            Decision::Allow,
6223                        );
6224                        let real_tool = permissions::evaluate_path_subject_safe_roots(
6225                            &rules,
6226                            name,
6227                            &roots,
6228                            p,
6229                            Decision::Allow,
6230                        );
6231                        d = d.stricter(pseudo).stricter(real_tool);
6232                    }
6233                }
6234                Err(_) => {
6235                    d = d.stricter(Decision::Ask);
6236                }
6237            }
6238            d
6239        } else if let Some(path) = path {
6240            let kind = if matches!(name, "write_file" | "edit_file") {
6241                PathKind::Write
6242            } else {
6243                PathKind::Read
6244            };
6245            // TWO independent sources of path-shaped rules can apply to the
6246            // same call, and BOTH must be checked:
6247            // (a) the `read(...)`/`write(...)` pseudo-tool (tool-agnostic —
6248            //     applies no matter WHICH tool touches the path; this is
6249            //     what `Config::permissions_protected_paths`/module 13
6250            //     expands into, via `protected_path_deny_rules`);
6251            // (b) a rule authored against the REAL tool name with the path
6252            //     as its subject — design §4.4's own oc-parity worked
6253            //     example writes exactly this shape (`"read_file(*.env)"`,
6254            //     not a pseudo-tool), matching how `bash(cmdglob)` rules
6255            //     are authored. `RuleSet::evaluate`'s bare-tool-name-glob
6256            //     branch (no parens) ALSO fires here regardless of
6257            //     `subject`, so this one call additionally covers a
6258            //     blanket "deny this tool entirely" rule — no separate
6259            //     `rules.evaluate(name, None)` call is needed.
6260            // SECURITY (CRITICAL fix, guarantor audit): both checks now
6261            // route through the safe-path-resolving variants (see
6262            // `evaluate_path_safe`'s doc comment) instead of glob-matching
6263            // the raw model-supplied `path` string directly — this is what
6264            // closes the traversal bypass (`write_file
6265            // path="x/../.git/config"`) and the analogous symlink escape.
6266            let pseudo_decision =
6267                permissions::evaluate_path_safe_roots(&rules, kind, &roots, path, default);
6268            let real_tool_decision =
6269                permissions::evaluate_path_subject_safe_roots(&rules, name, &roots, path, default);
6270            pseudo_decision.stricter(real_tool_decision)
6271        } else {
6272            rules.evaluate(name, None).unwrap_or(default)
6273        };
6274
6275        // BP-10 (catalog row "Hook/plugin permission veto"): the hook's
6276        // verdict is a TIER of this engine, folded in the one direction
6277        // that is always safe — `Ask` tightens (`stricter` never loosens),
6278        // `Deny` is a floor. `Allow` deliberately does NOT change the
6279        // decision here: it answers the `Ask` tier below (the
6280        // `PermissionRequest`-class reply CC's hooks give on the user's
6281        // behalf), so a `deny` rule still refuses the call outright — a
6282        // hook may skip a prompt, never a floor.
6283        let decision = match hook {
6284            crate::config::HookDecision::Deny => Decision::Deny,
6285            crate::config::HookDecision::Ask => decision.stricter(Decision::Ask),
6286            crate::config::HookDecision::Allow | crate::config::HookDecision::Pass => decision,
6287        };
6288
6289        // BP-10 (catalog row "Sandbox-escalation path", cx§4
6290        // `sandbox_permissions: "require_escalated"` + justification): the
6291        // model's channel to ASK for an unsandboxed run is a rule inside
6292        // this one engine, not a switch beside it. A call carrying
6293        // `with_escalated_permissions: true` is forced to at least the
6294        // `Ask` tier — never below whatever the rules already decided, so
6295        // a denied command cannot escalate its way out (`stricter` only
6296        // tightens), and never silently allowed under
6297        // `ApprovalPolicy::ModelRequested`/`Never`, whose `Allow` baseline
6298        // is exactly what made "the model requests escalation" a no-op
6299        // before. The `justification` rides in `raw_args` below, so the
6300        // approval door shows the user the model's own reason.
6301        let decision = if args
6302            .get("with_escalated_permissions")
6303            .and_then(|v| v.as_bool())
6304            .unwrap_or(false)
6305        {
6306            decision.stricter(Decision::Ask)
6307        } else {
6308            decision
6309        };
6310
6311        // D-2 (Fable-5 delta review — LOW-MEDIUM): `command`/`path`/`patch`
6312        // above are extracted (and used to DRIVE the decision above) for
6313        // ANY tool that happens to carry one of those arg names — that
6314        // part is unchanged and correct (a rule authored against, say, an
6315        // MCP tool's own name legitimately wants to glob-match its
6316        // `command`-shaped arg too). But the narrower single-field
6317        // `subject` handed to the cache/handler below must NOT do the
6318        // same for a non-built-in tool: an MCP (or other) tool's
6319        // `command`/`path` is just one field among potentially several
6320        // that together define what the call actually does — collapsing
6321        // an `AllowForSession` grant down to that one field would silently
6322        // auto-allow a later call with the SAME `command` but different
6323        // OTHER args (e.g. `{"command":"sync","target":"staging"}`
6324        // auto-allowing `{"command":"sync","target":"production"}`).
6325        // Restricting this to the known built-ins whose `subject` really
6326        // IS the whole call leaves every other tool with `subject: None`,
6327        // which routes it through `ApprovalCache::key_for_request`'s
6328        // full-args-digest fallback (F2) instead.
6329        let subject = Self::SUBJECT_BEARING_BUILTIN_TOOLS
6330            .contains(&name)
6331            .then(|| command.or(path).or(patch))
6332            .flatten();
6333        let req = permissions::ApprovalRequest {
6334            tool: name,
6335            subject,
6336            raw_args: args,
6337        };
6338        let approved = permissions::decision_to_approved(decision, || {
6339            // BP-10: a hook `Allow` answers this ask without a prompt (and
6340            // without a cache entry — the hook is consulted on every call,
6341            // so caching its answer would be a second, staler copy of the
6342            // same decision).
6343            if hook == crate::config::HookDecision::Allow {
6344                return true;
6345            }
6346            permissions::resolve_ask(&self.permissions_approval_cache, handler, &req)
6347        });
6348        if approved {
6349            None
6350        } else {
6351            Some(format!(
6352                "tool `{name}` was not approved for execution (permissions engine: {decision:?})"
6353            ))
6354        }
6355    }
6356
6357    /// P5-6 (§2.2 C6, build brief "a bg `rm -rf` subject to the same deny
6358    /// rules... must never escalate past what a foreground exec of the
6359    /// same command is allowed"): the permission gate `background_exec`
6360    /// runs BEFORE spawning anything. Evaluated against the tool name
6361    /// `"bash"` (not `"background_exec"`) deliberately — so any
6362    /// `bash(...)`-authored deny/ask/allow rule (or protected-path floor)
6363    /// applies to a background command byte-for-byte identically to a
6364    /// foreground `bash` call, the SAME rule set + default baseline
6365    /// [`Self::permissions_gate_denial`] would use for one.
6366    ///
6367    /// The one deliberate difference (C6 itself): an `Ask`-tier decision
6368    /// NEVER reaches an interactive handler here — a background job has no
6369    /// way to block on a prompt it can't answer. When
6370    /// [`Config::subagents_background_prompts`] is
6371    /// [`crate::subagents::BackgroundPromptsPolicy::Parent`], the denied
6372    /// request is additionally queued onto [`Self::pending_child_approvals`]
6373    /// (via [`crate::subagents::ParentQueueApprovalHandler`], reused
6374    /// verbatim — the SAME "parent-surfaced queue" §2.2 C6 names for
6375    /// `subagents.background`, with the job id standing in for a child
6376    /// agent id) for later inspection; any other configuration (including
6377    /// no `background_prompts` set at all) resolves via `handler: None` —
6378    /// [`crate::permissions::resolve_ask`]'s pre-existing "no handler ⇒
6379    /// deny" fail-closed default, identical to `subagents`'s own
6380    /// `AutoPolicy` reading. Either way, `Ask` always denies; only `Allow`
6381    /// (from the rule engine itself, or a PRIOR interactively-granted
6382    /// `AllowForSession` cache entry) ever lets a background command run —
6383    /// so this can only ever be as-or-more restrictive than a foreground
6384    /// call, never looser, regardless of configuration.
6385    ///
6386    /// Covers BOTH gate generations: when [`Config::permissions_enabled`]
6387    /// is on, the P5-1 engine (above) is used; otherwise the legacy
6388    /// [`Config::needs_approval`] gate is consulted but its
6389    /// `approval_handler` closure is NEVER invoked (that closure could
6390    /// itself block, e.g. a real interactive prompt) — an approval-required
6391    /// legacy policy simply denies a background command outright, the same
6392    /// never-hang guarantee under the older gate.
6393    fn background_permission_denial(
6394        &self,
6395        command: &str,
6396        job_id: &str,
6397        hook: crate::config::HookDecision,
6398    ) -> Option<String> {
6399        let args = serde_json::json!({ "command": command });
6400        if self.config.permissions_enabled {
6401            if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
6402                self.config.subagents_background_prompts
6403            {
6404                let handler = crate::subagents::ParentQueueApprovalHandler {
6405                    child_agent_id: format!("bg:{job_id}"),
6406                    queue: self.pending_child_approvals.clone(),
6407                };
6408                self.permissions_gate_denial_impl("bash", &args, Some(&handler), hook)
6409            } else {
6410                self.permissions_gate_denial_impl("bash", &args, None, hook)
6411            }
6412        } else if self.config.needs_approval("bash") {
6413            Some(
6414                "tool `bash` requires approval, which a background job cannot request \
6415                 interactively (§2.2 C6: auto-policy denies)"
6416                    .to_string(),
6417            )
6418        } else {
6419            None
6420        }
6421    }
6422
6423    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
6424    /// sibling calls run concurrently"): runs `calls`' `Tool::execute()`
6425    /// futures CONCURRENTLY via `futures::future::join_all`, for whichever
6426    /// calls [`Self::prepare_tool_call`] resolves to [`PreparedCall::Ready`]
6427    /// — i.e. every plain (non-intrinsic) registry-tool call that passes
6428    /// its synchronous approval/doom-loop/pre-tool-hook checks. A call that
6429    /// resolves to [`PreparedCall::Done`] (an intrinsic, an unknown tool, a
6430    /// denied/blocked call) is NOT parallelized — its result is already in
6431    /// hand from the synchronous prepare pass. Every prepare check still
6432    /// runs sequentially, in original call order, before ANY `execute()`
6433    /// future starts (only the actual tool I/O overlaps) — so doom-loop
6434    /// bookkeeping and pre-tool-hook vetoes see the exact same call order
6435    /// they would under the sequential path. Returns results in the SAME
6436    /// order as `calls`, so callers can always `zip` the two. Post-tool
6437    /// hooks fire per call, in original order, once every result is in
6438    /// hand — a caller-visible timing difference from the sequential path
6439    /// ONLY when this method runs at all (i.e. only when
6440    /// `Config::parallel_tool_calls` is on): hooks see "this batch
6441    /// finished" ordering rather than "this one call finished" ordering.
6442    /// Documented, not a bug.
6443    async fn run_tools_concurrently(
6444        &mut self,
6445        calls: &[crate::message::ToolCall],
6446    ) -> Vec<(String, bool)> {
6447        // P5-3: `spawn_subagent`/`subagent_status` need sequential `&mut
6448        // self` access `prepare_tool_call`'s synchronous-only signature
6449        // can't give them (see `Self::run_tool`'s identical interception).
6450        // A batch that includes one falls back to dispatching the WHOLE
6451        // batch sequentially via `Self::run_tool` — a documented, narrow
6452        // simplification (not a partial-parallelization attempt) rather
6453        // than restructuring `PreparedCall` to carry a future; a batch with
6454        // no subagent intrinsic is completely unaffected and still
6455        // parallelizes exactly as before.
6456        if calls.iter().any(|c| {
6457            c.function.name == SPAWN_SUBAGENT
6458                || c.function.name == SUBAGENT_STATUS
6459                || c.function.name == SUBAGENT_MESSAGE
6460                || c.function.name == SUBAGENT_RESUME
6461                || (self.config.claude_runtime_tools_enabled
6462                    && matches!(
6463                        c.function.name.as_str(),
6464                        CLAUDE_CRON_CREATE
6465                            | CLAUDE_CRON_DELETE
6466                            | CLAUDE_CRON_LIST
6467                            | CLAUDE_SCHEDULE_WAKEUP
6468                    ))
6469        }) {
6470            let mut out = Vec::with_capacity(calls.len());
6471            for call in calls {
6472                out.push(self.run_tool(call).await);
6473            }
6474            return out;
6475        }
6476        let prepared: Vec<PreparedCall> = calls.iter().map(|c| self.prepare_tool_call(c)).collect();
6477        let mut slots: Vec<Option<(String, bool)>> = prepared
6478            .iter()
6479            .map(|p| match p {
6480                PreparedCall::Done(r) => Some(r.clone()),
6481                PreparedCall::Ready { .. } => None,
6482            })
6483            .collect();
6484
6485        let ready_idxs: Vec<usize> = prepared
6486            .iter()
6487            .enumerate()
6488            .filter(|(_, p)| matches!(p, PreparedCall::Ready { .. }))
6489            .map(|(i, _)| i)
6490            .collect();
6491
6492        if !ready_idxs.is_empty() {
6493            let futs = ready_idxs.iter().map(|&i| {
6494                let PreparedCall::Ready { name, args } = &prepared[i] else {
6495                    unreachable!("filtered to Ready above")
6496                };
6497                // `self.registry.get` borrows `self.registry` immutably;
6498                // `self.ctx` is `Clone` (P4c precedent) so each future owns
6499                // its own copy rather than borrowing `self` across the
6500                // `.await` inside `join_all`.
6501                let tool = self.registry.get(name).expect("prepared as Ready");
6502                let args = args.clone();
6503                let ctx = self.ctx.clone();
6504                async move {
6505                    match tool.execute(args, &ctx).await {
6506                        Ok(out) => (out, false),
6507                        Err(e) => (format!("Error: {e}"), true),
6508                    }
6509                }
6510            });
6511            let results = futures::future::join_all(futs).await;
6512            for (idx, result) in ready_idxs.iter().zip(results) {
6513                slots[*idx] = Some(result);
6514            }
6515        }
6516
6517        let out: Vec<(String, bool)> = slots
6518            .into_iter()
6519            .map(|s| s.expect("every call resolved to Some above"))
6520            .collect();
6521        // Post-tool hook, in original order — only for calls that actually
6522        // reached `execute()` (matches `run_tool`'s existing behavior: an
6523        // intrinsic/denied/blocked call never fires the post-tool hook).
6524        let ready_set: std::collections::HashSet<usize> = ready_idxs.into_iter().collect();
6525        for (i, call) in calls.iter().enumerate() {
6526            if !ready_set.contains(&i) {
6527                continue;
6528            }
6529            let (output, is_error) = &out[i];
6530            if let Some(hook) = &self.config.post_tool_hook {
6531                hook(&call.function.name, output, *is_error);
6532            }
6533        }
6534        out
6535    }
6536
6537    /// P4c (§5.2 P4 "doom-loop breaker", §3.1 `core.doom_loop_threshold`):
6538    /// update the consecutive-identical-call streak for `(name, args)` and
6539    /// return `Some(reason)` the moment the streak reaches
6540    /// `Config.doom_loop_threshold` (a call whose name AND JSON-canonical
6541    /// arguments are byte-identical to the immediately preceding call
6542    /// extends the streak; anything else resets it to 1). `None`
6543    /// (`Config.doom_loop_threshold` unset, or `Some(n)` with `n < 2` — a
6544    /// threshold below 2 can never fire since the FIRST call already
6545    /// "repeats zero times") never touches the streak fields at all.
6546    fn check_doom_loop(&mut self, name: &str, args: &serde_json::Value) -> Option<String> {
6547        let threshold = self.config.doom_loop_threshold?;
6548        if threshold < 2 {
6549            return None;
6550        }
6551        // `serde_json::Value::Object` is a `BTreeMap` in this workspace (no
6552        // `preserve_order` feature), so `to_string()` is already
6553        // key-order-canonical — two calls that differ only in argument key
6554        // order are still treated as identical.
6555        let key = (name.to_string(), args.to_string());
6556        if self.doom_loop_last_call.as_ref() == Some(&key) {
6557            self.doom_loop_streak += 1;
6558        } else {
6559            self.doom_loop_last_call = Some(key);
6560            self.doom_loop_streak = 1;
6561        }
6562        if self.doom_loop_streak >= threshold {
6563            Some(format!(
6564                "doom-loop breaker: `{name}` called with identical arguments {} times in a row \
6565                 — try a different approach instead of repeating the same call",
6566                self.doom_loop_streak
6567            ))
6568        } else {
6569            None
6570        }
6571    }
6572
6573    /// Whether `name` is in the eagerly-advertised "core" set for the current
6574    /// [`ToolAdvertising`] mode: every enabled tool under `Full`, or the
6575    /// explicit `core` allowlist under `Deferred`.
6576    fn is_core_tool(&self, name: &str) -> bool {
6577        match &self.config.tool_advertising {
6578            ToolAdvertising::Full => true,
6579            ToolAdvertising::Deferred { core } => core.iter().any(|c| c == name),
6580        }
6581    }
6582
6583    /// The schema advertised on the wire for `t`: the raw (as-shipped)
6584    /// schema with TR-8/T5's per-tool schema tier applied. This is what
6585    /// [`Self::tool_schemas`] sends every request.
6586    fn schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
6587        let raw = self.raw_schema_for(t);
6588        let tier = self.config.schema_tier_for(t.name());
6589        let (description, parameters) =
6590            crate::tools::tiers::minify(&raw.description, &raw.parameters, tier);
6591        ToolSchema {
6592            name: raw.name,
6593            description,
6594            parameters,
6595        }
6596    }
6597
6598    /// The ORIGINAL, as-shipped schema for `t` — never tier-minified. This is
6599    /// the full contract [`Self::run_tool_search`] hands back on activation
6600    /// (TR-8/T5 dev/03: the B6 fetch path is the invert of tiering, so a
6601    /// model that fetched a tool via `tool_search` always sees the complete
6602    /// schema, byte-equal to `t.description()`/`t.parameters()` — modulo the
6603    /// pre-existing [`crate::Config::tool_description`] override, which is
6604    /// orthogonal to tiering).
6605    fn raw_schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
6606        ToolSchema {
6607            name: t.name().to_string(),
6608            description: self
6609                .config
6610                .tool_description(t.name(), t.description())
6611                .to_string(),
6612            parameters: t.parameters(),
6613        }
6614    }
6615
6616    /// The synthetic `tool_search` schema advertised under `Deferred` (B6).
6617    fn tool_search_schema() -> ToolSchema {
6618        ToolSchema {
6619            name: TOOL_SEARCH.to_string(),
6620            description: "Search for additional tools not currently advertised (the deferred \
6621                MCP surface and any other non-core tools). Matches keywords case-insensitively \
6622                against each tool's name and description. Matched tools become callable starting \
6623                with your NEXT message, not this one."
6624                .to_string(),
6625            parameters: serde_json::json!({
6626                "type": "object",
6627                "properties": {
6628                    "query": {
6629                        "type": "string",
6630                        "description": "Keyword(s) to search for in tool names and descriptions."
6631                    },
6632                    "max_results": {
6633                        "type": "integer",
6634                        "description": "Maximum number of matching tools to return."
6635                    }
6636                },
6637                "required": ["query"],
6638                "additionalProperties": false
6639            }),
6640        }
6641    }
6642
6643    /// The tool-schema array this agent would advertise on its NEXT
6644    /// request, exactly as `Self::run_loop` computes it. Public
6645    /// (PARITY-18 D1) so a caller can measure the real request-token cost
6646    /// of an agent's tool surface — including the current
6647    /// [`crate::config::ToolAdvertising`] mode's core/deferred split and
6648    /// the synthetic `tool_search`/`expand_reduction`/`sidecar_search`
6649    /// schemas — BEFORE ever calling [`Self::send`], e.g. for a preflight
6650    /// context-guard check.
6651    pub fn tool_schemas(&self) -> Vec<ToolSchema> {
6652        let mut out = match &self.config.tool_advertising {
6653            ToolAdvertising::Full => self
6654                .registry
6655                .iter()
6656                .filter(|t| self.config.tool_enabled(t.name()))
6657                .map(|t| self.schema_for(t))
6658                .collect(),
6659            ToolAdvertising::Deferred { .. } => {
6660                let mut out: Vec<ToolSchema> = self
6661                    .registry
6662                    .iter()
6663                    .filter(|t| self.config.tool_enabled(t.name()))
6664                    .filter(|t| {
6665                        self.is_core_tool(t.name()) || self.activated_tools.contains(t.name())
6666                    })
6667                    .map(|t| self.schema_for(t))
6668                    .collect();
6669                out.push(Self::tool_search_schema());
6670                out
6671            }
6672        };
6673        // T12/TR-1: `expand_reduction`/`sidecar_search` are orthogonal to
6674        // `tool_advertising` (which governs the ordinary tool surface) —
6675        // advertised whenever a `ReductionPolicy` is installed, regardless of
6676        // Full/Deferred, since only a reduced session ever has anything to
6677        // expand or search (SPEC.md TR-1 dev/01).
6678        if self.reduction_policy.is_some() {
6679            out.push(Self::expand_reduction_schema());
6680            out.push(Self::sidecar_search_schema());
6681        }
6682        // P5-3 (§2 module 9): `spawn_subagent`/`subagent_status` are
6683        // orthogonal to `tool_advertising` too, same reasoning as
6684        // `expand_reduction`/`sidecar_search` above — advertised whenever
6685        // `Config::subagents_enabled` is on, Full or Deferred alike.
6686        // `false` (the default) never appends either, so a config that
6687        // never turns the module on gets byte-identical tool schemas to
6688        // today.
6689        if self.config.subagents_enabled {
6690            out.push(self.spawn_subagent_schema());
6691            if self.config.subagents_claude_agent_alias {
6692                out.push(self.claude_agent_schema());
6693            }
6694            if self.config.subagents_background {
6695                out.push(Self::subagent_status_schema());
6696                // BP-7 (catalog §4a "Background subagents + resume"): the
6697                // two halves the row named as missing — a mailbox into a
6698                // still-running child, and a resume of a finished one with
6699                // its context intact.
6700                out.push(Self::subagent_message_schema());
6701                out.push(Self::subagent_resume_schema());
6702            }
6703        }
6704        if self.config.claude_runtime_tools_enabled {
6705            out.extend(self.claude_builtin_tool_schemas());
6706            out.push(Self::claude_cron_create_schema());
6707            out.push(Self::claude_cron_delete_schema());
6708            out.push(Self::claude_cron_list_schema());
6709            out.push(Self::claude_schedule_wakeup_schema());
6710        }
6711        // P5-6 (§2 module 4 `tools.background`): same orthogonal-to-
6712        // `tool_advertising` treatment, advertised whenever
6713        // `Config::tools_background_enabled` is on. `false` (the default)
6714        // never appends any of the four, so a config that never turns the
6715        // module on gets byte-identical tool schemas to today.
6716        if self.config.tools_background_enabled {
6717            out.push(Self::background_exec_schema());
6718            out.push(Self::background_status_schema());
6719            out.push(Self::background_list_schema());
6720            out.push(Self::background_kill_schema());
6721        }
6722        // BP-10 (catalog row "Tool hiding via policy", cc§4 "bare-name
6723        // deny"): a policy deny does not merely REFUSE the call at
6724        // dispatch — it removes the tool from the model's view. Applied
6725        // once, here, over the finished array, so every family appended
6726        // above (`spawn_subagent`, `background_*`, the Claude aliases,
6727        // `expand_reduction`, …) is hidden by the same one rule, not by a
6728        // per-family repeat of it. See [`Self::policy_hides_tool`] for
6729        // which deny tier is consulted and why.
6730        out.retain(|schema| !self.policy_hides_tool(&schema.name));
6731        out
6732    }
6733
6734    /// BP-10: whether the CONFIG-DECLARED deny tier hides `name` from the
6735    /// model's tool surface entirely (cc§4: CC's bare-name deny "removes
6736    /// the tool from the model's view", where an ordinary rule only
6737    /// refuses the call).
6738    ///
6739    /// The ONE engine decides: this is
6740    /// [`crate::permissions::RuleSet::evaluate`] with `subject: None`, so
6741    /// exactly the patterns that can be satisfied by a tool NAME ALONE
6742    /// (`"bash"`, `"mcp_*"`, `"*"`) hide; a rule that names a
6743    /// command/path constraint (`"bash(rm -rf*)"`, `"write(.git/**)"`) is
6744    /// not satisfiable without a subject and therefore never hides a tool
6745    /// — the same `rule_matches` contract the dispatch gate uses.
6746    ///
6747    /// **Which deny tier.** `Config::tool_deny_patterns` — the
6748    /// `capabilities.permissions.rules.deny` array — and NOT the two
6749    /// runtime narrowings the dispatch gate folds in beside it:
6750    /// `protected_paths` expands to `read(...)`/`write(...)` patterns that
6751    /// carry a subject by construction (so they could never match here
6752    /// anyway), and `plan_mode::deny_rules` is a MODE, not a policy — CC's
6753    /// plan mode refuses a write, it does not make Write disappear and
6754    /// reappear as the mode toggles mid-session. Hiding is a property of
6755    /// the configured policy, which is fixed for the run.
6756    ///
6757    /// Gated on [`Config::permissions_enabled`]: a config that never turns
6758    /// the module on gets byte-identical schemas to before this existed.
6759    fn policy_hides_tool(&self, name: &str) -> bool {
6760        if !self.config.permissions_enabled || self.config.tool_deny_patterns.is_empty() {
6761            return false;
6762        }
6763        let rules = crate::permissions::RuleSet {
6764            deny: self.config.tool_deny_patterns.clone(),
6765            ..Default::default()
6766        };
6767        rules.evaluate(name, None) == Some(crate::permissions::Decision::Deny)
6768    }
6769
6770    fn claude_builtin_tool_schemas(&self) -> Vec<ToolSchema> {
6771        let mut schemas = Vec::new();
6772        let mut push = |alias: &str, native: &str, description: &str, parameters| {
6773            if self.registry.get(native).is_some() && self.config.tool_enabled(native) {
6774                schemas.push(ToolSchema {
6775                    name: alias.to_string(),
6776                    description: description.to_string(),
6777                    parameters,
6778                });
6779            }
6780        };
6781        push(
6782            CLAUDE_BASH,
6783            "bash",
6784            "Claude Code-compatible shell command execution.",
6785            serde_json::json!({
6786                "type": "object",
6787                "properties": {
6788                    "command": {"type": "string"},
6789                    "timeout": {"type": "integer", "description": "Timeout in milliseconds."},
6790                    "description": {"type": "string"}
6791                },
6792                "required": ["command"],
6793                "additionalProperties": true
6794            }),
6795        );
6796        push(
6797            CLAUDE_READ,
6798            "read_file",
6799            "Claude Code-compatible file reader.",
6800            serde_json::json!({
6801                "type": "object",
6802                "properties": {
6803                    "file_path": {"type": "string"},
6804                    "offset": {"type": "integer"},
6805                    "limit": {"type": "integer"}
6806                },
6807                "required": ["file_path"],
6808                "additionalProperties": false
6809            }),
6810        );
6811        push(
6812            CLAUDE_WRITE,
6813            "write_file",
6814            "Claude Code-compatible file writer.",
6815            serde_json::json!({
6816                "type": "object",
6817                "properties": {"file_path": {"type": "string"}, "content": {"type": "string"}},
6818                "required": ["file_path", "content"],
6819                "additionalProperties": false
6820            }),
6821        );
6822        push(
6823            CLAUDE_EDIT,
6824            "edit_file",
6825            "Claude Code-compatible exact file edit.",
6826            serde_json::json!({
6827                "type": "object",
6828                "properties": {
6829                    "file_path": {"type": "string"},
6830                    "old_string": {"type": "string"},
6831                    "new_string": {"type": "string"},
6832                    "replace_all": {"type": "boolean"}
6833                },
6834                "required": ["file_path", "old_string", "new_string"],
6835                "additionalProperties": false
6836            }),
6837        );
6838        push(
6839            CLAUDE_GLOB,
6840            "glob",
6841            "Claude Code-compatible file glob.",
6842            serde_json::json!({
6843                "type": "object",
6844                "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
6845                "required": ["pattern"],
6846                "additionalProperties": false
6847            }),
6848        );
6849        push(
6850            CLAUDE_GREP,
6851            "search",
6852            "Claude Code-compatible content search.",
6853            serde_json::json!({
6854                "type": "object",
6855                "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
6856                "required": ["pattern"],
6857                "additionalProperties": true
6858            }),
6859        );
6860        schemas
6861    }
6862
6863    fn translate_claude_builtin_call(
6864        &self,
6865        call: &crate::message::ToolCall,
6866    ) -> Result<Option<crate::message::ToolCall>> {
6867        let native = match call.function.name.as_str() {
6868            CLAUDE_BASH => "bash",
6869            CLAUDE_READ => "read_file",
6870            CLAUDE_WRITE => "write_file",
6871            CLAUDE_EDIT => "edit_file",
6872            CLAUDE_GLOB => "glob",
6873            CLAUDE_GREP => "search",
6874            _ => return Ok(None),
6875        };
6876        let mut args = call.function.parsed_arguments()?;
6877        let object = args
6878            .as_object_mut()
6879            .ok_or_else(|| Error::InvalidArguments {
6880                tool: call.function.name.clone(),
6881                message: "expected a JSON object".to_string(),
6882            })?;
6883        if let Some(path) = object.remove("file_path") {
6884            object.entry("path".to_string()).or_insert(path);
6885        }
6886        if call.function.name == CLAUDE_BASH {
6887            if let Some(timeout) = object.remove("timeout") {
6888                object.entry("timeout_ms".to_string()).or_insert(timeout);
6889            }
6890        }
6891        if call.function.name == CLAUDE_GLOB {
6892            if let Some(path) = object
6893                .remove("path")
6894                .and_then(|value| value.as_str().map(str::to_owned))
6895            {
6896                if let Some(pattern) = object.get_mut("pattern") {
6897                    if let Some(value) = pattern.as_str() {
6898                        if !std::path::Path::new(value).is_absolute() {
6899                            *pattern = serde_json::Value::String(
6900                                std::path::Path::new(&path)
6901                                    .join(value)
6902                                    .to_string_lossy()
6903                                    .into_owned(),
6904                            );
6905                        }
6906                    }
6907                }
6908            }
6909        }
6910        let mut translated = call.clone();
6911        translated.function.name = native.to_string();
6912        translated.function.arguments = serde_json::to_string(&args)?;
6913        Ok(Some(translated))
6914    }
6915
6916    fn claude_cron_create_schema() -> ToolSchema {
6917        ToolSchema {
6918            name: CLAUDE_CRON_CREATE.to_string(),
6919            description: "Record a Claude-compatible cron job in the imported runtime manifest. \
6920                The job inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler, \
6921                not this agent loop, owns execution."
6922                .to_string(),
6923            parameters: serde_json::json!({
6924                "type": "object",
6925                "properties": {
6926                    "cron": {"type": "string", "description": "Cron expression to preserve."},
6927                    "prompt": {"type": "string", "description": "Prompt associated with the job."},
6928                    "recurring": {"type": "boolean", "default": false},
6929                    "durable": {"type": "boolean", "default": false}
6930                },
6931                "required": ["cron", "prompt"],
6932                "additionalProperties": false
6933            }),
6934        }
6935    }
6936
6937    fn claude_cron_delete_schema() -> ToolSchema {
6938        ToolSchema {
6939            name: CLAUDE_CRON_DELETE.to_string(),
6940            description: "Delete a Claude-compatible cron job from the imported manifest. \
6941                This updates state only; an embedding scheduler owns execution."
6942                .to_string(),
6943            parameters: serde_json::json!({
6944                "type": "object",
6945                "properties": {"id": {"type": "string"}},
6946                "required": ["id"],
6947                "additionalProperties": false
6948            }),
6949        }
6950    }
6951
6952    fn claude_cron_list_schema() -> ToolSchema {
6953        ToolSchema {
6954            name: CLAUDE_CRON_LIST.to_string(),
6955            description: "List imported Claude cron jobs and their explicit ACTIVE or PAUSED \
6956                manifest posture. This agent loop itself does not run a scheduler."
6957                .to_string(),
6958            parameters: serde_json::json!({
6959                "type": "object",
6960                "properties": {},
6961                "additionalProperties": false
6962            }),
6963        }
6964    }
6965
6966    fn claude_schedule_wakeup_schema() -> ToolSchema {
6967        ToolSchema {
6968            name: CLAUDE_SCHEDULE_WAKEUP.to_string(),
6969            description: "Replace the one-shot wakeup stored in the imported Claude manifest. \
6970                The wakeup inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler \
6971                owns timer execution."
6972                .to_string(),
6973            parameters: serde_json::json!({
6974                "type": "object",
6975                "properties": {
6976                    "delaySeconds": {"type": "integer", "minimum": 0},
6977                    "reason": {"type": "string"},
6978                    "prompt": {"type": "string"}
6979                },
6980                "required": ["delaySeconds"],
6981                "additionalProperties": false
6982            }),
6983        }
6984    }
6985
6986    /// The `background_exec` schema (P5-6, §2 module 4, D1 "background
6987    /// exec").
6988    fn background_exec_schema() -> ToolSchema {
6989        ToolSchema {
6990            name: BACKGROUND_EXEC.to_string(),
6991            description: "Run a shell command in the BACKGROUND: spawns it as a detached \
6992                process and returns a `job_id` IMMEDIATELY, before the command finishes — this \
6993                call never returns the command's output. Poll `background_status` with the \
6994                `job_id` to check progress and retrieve captured output; use `background_kill` \
6995                to cancel it early. The command goes through the exact same sandbox/permission \
6996                checks as a foreground `bash` call, and any check that would need an \
6997                interactive approval is denied automatically (a background job cannot wait for \
6998                one)."
6999                .to_string(),
7000            parameters: serde_json::json!({
7001                "type": "object",
7002                "properties": {
7003                    "command": {
7004                        "type": "string",
7005                        "description": "Shell command to run in the background via `sh -c`."
7006                    }
7007                },
7008                "required": ["command"],
7009                "additionalProperties": false
7010            }),
7011        }
7012    }
7013
7014    /// The `background_status` schema (P5-6, D1 "monitor/event feed").
7015    fn background_status_schema() -> ToolSchema {
7016        ToolSchema {
7017            name: BACKGROUND_STATUS.to_string(),
7018            description: "Check on a background job spawned via background_exec: its \
7019                running/exited/killed status, exit code (once known), and the command's \
7020                captured stdout/stderr so far (bounded — very large output is truncated with a \
7021                marker). Once the job has exited or been killed, this call also reaps it (it \
7022                will no longer appear in background_list or accept further status polls)."
7023                .to_string(),
7024            parameters: serde_json::json!({
7025                "type": "object",
7026                "properties": {
7027                    "job_id": {
7028                        "type": "string",
7029                        "description": "The id `background_exec` returned when this job was \
7030                            started."
7031                    }
7032                },
7033                "required": ["job_id"],
7034                "additionalProperties": false
7035            }),
7036        }
7037    }
7038
7039    /// The `background_list` schema (P5-6, D10 "bg-manager").
7040    fn background_list_schema() -> ToolSchema {
7041        ToolSchema {
7042            name: BACKGROUND_LIST.to_string(),
7043            description: "List every background job currently tracked (running, or finished \
7044                but not yet polled via background_status) — job id, command, status, pid, and \
7045                start time for each. Does not retrieve output or reap anything."
7046                .to_string(),
7047            parameters: serde_json::json!({
7048                "type": "object",
7049                "properties": {},
7050                "additionalProperties": false
7051            }),
7052        }
7053    }
7054
7055    /// The `background_kill` schema (P5-6, D10 "bg-manager").
7056    fn background_kill_schema() -> ToolSchema {
7057        ToolSchema {
7058            name: BACKGROUND_KILL.to_string(),
7059            description: "Kill a background job's real process immediately (a no-op, not an \
7060                error, if it already exited on its own) and reap it."
7061                .to_string(),
7062            parameters: serde_json::json!({
7063                "type": "object",
7064                "properties": {
7065                    "job_id": {
7066                        "type": "string",
7067                        "description": "The id `background_exec` returned when this job was \
7068                            started."
7069                    }
7070                },
7071                "required": ["job_id"],
7072                "additionalProperties": false
7073            }),
7074        }
7075    }
7076
7077    /// The `spawn_subagent` schema (P5-3, §2 module 9 D1 "spawn tool").
7078    /// Lists every configured `agent_type` name so the model knows what's
7079    /// available, but `agent_type` stays optional — an ad-hoc spawn with an
7080    /// inline `system_prompt` is always allowed too.
7081    fn spawn_subagent_schema(&self) -> ToolSchema {
7082        let mut names: Vec<&str> = self
7083            .config
7084            .subagents_definitions
7085            .keys()
7086            .map(String::as_str)
7087            .collect();
7088        names.sort_unstable();
7089        let agent_type_desc = if names.is_empty() {
7090            "Optional named subagent type to run (none configured — omit this and pass \
7091             `system_prompt` instead)."
7092                .to_string()
7093        } else {
7094            format!(
7095                "Optional named subagent type to run: {}. Omit to run an ad-hoc subagent with \
7096                 your own `system_prompt` instead.",
7097                names.join(", ")
7098            )
7099        };
7100        let background_desc = if self.config.subagents_background {
7101            "Run this subagent in the background instead of waiting for it — this call \
7102             returns immediately with a `subagent_id`; poll `subagent_status` with that id for \
7103             the result."
7104        } else {
7105            "Background subagents are disabled for this agent — this must be omitted or false."
7106        };
7107        ToolSchema {
7108            name: SPAWN_SUBAGENT.to_string(),
7109            description: "Spawn a subagent to work on a self-contained task and (by default) \
7110                wait for its final answer, which is returned as this call's result. The \
7111                subagent runs its own independent reasoning/tool loop; it does not see your \
7112                conversation except for the `task` text you give it here."
7113                .to_string(),
7114            parameters: serde_json::json!({
7115                "type": "object",
7116                "properties": {
7117                    "task": {
7118                        "type": "string",
7119                        "description": "The self-contained task/prompt for the subagent."
7120                    },
7121                    "agent_type": {
7122                        "type": "string",
7123                        "description": agent_type_desc
7124                    },
7125                    "system_prompt": {
7126                        "type": "string",
7127                        "description": "Inline system prompt for an ad-hoc subagent (ignored \
7128                            if `agent_type` is given — the named type's own prompt is used \
7129                            instead)."
7130                    },
7131                    "background": {
7132                        "type": "boolean",
7133                        "description": background_desc
7134                    }
7135                },
7136                "required": ["task"],
7137                "additionalProperties": false
7138            }),
7139        }
7140    }
7141
7142    /// Claude Code-compatible alias for [`Self::spawn_subagent_schema`].
7143    fn claude_agent_schema(&self) -> ToolSchema {
7144        let mut names: Vec<String> = self.config.subagents_definitions.keys().cloned().collect();
7145        names.push("general-purpose".into());
7146        names.sort_unstable();
7147        names.dedup();
7148        ToolSchema {
7149            name: CLAUDE_AGENT.to_string(),
7150            description: "Claude Code-compatible subagent dispatcher. Runs a named or ad-hoc \
7151                child agent; children default to background execution in this compatibility mode."
7152                .to_string(),
7153            parameters: serde_json::json!({
7154                "type": "object",
7155                "properties": {
7156                    "prompt": {"type": "string", "description": "Self-contained child task."},
7157                    "subagent_type": {
7158                        "type": "string",
7159                        "description": format!("Named agent type. Available: {}", names.join(", "))
7160                    },
7161                    "description": {
7162                        "type": "string",
7163                        "description": "Short human-facing task label; preserved as descriptive input."
7164                    },
7165                    "model": {
7166                        "type": "string",
7167                        "description": "Optional model alias or full provider slug for this child."
7168                    },
7169                    "run_in_background": {
7170                        "type": "boolean",
7171                        "description": "Whether to return immediately with a child id (default true)."
7172                    }
7173                },
7174                "required": ["prompt"],
7175                "additionalProperties": false
7176            }),
7177        }
7178    }
7179
7180    /// Translate Claude's `Agent` arguments to the native subagent intrinsic.
7181    fn translate_claude_agent_call(
7182        &self,
7183        call: &crate::message::ToolCall,
7184    ) -> Result<crate::message::ToolCall> {
7185        let args = call
7186            .function
7187            .parsed_arguments()
7188            .map_err(|error| Error::InvalidArguments {
7189                tool: CLAUDE_AGENT.to_string(),
7190                message: error.to_string(),
7191            })?;
7192        let object = args.as_object().ok_or_else(|| Error::InvalidArguments {
7193            tool: CLAUDE_AGENT.to_string(),
7194            message: "arguments must be an object".to_string(),
7195        })?;
7196        let mut translated = serde_json::Map::new();
7197        if let Some(value) = object.get("prompt") {
7198            translated.insert("task".to_string(), value.clone());
7199        }
7200        if let Some(value) = object.get("subagent_type") {
7201            // `general-purpose` is a built-in Claude agent, not a project
7202            // definition file. Supercode's equivalent is an ad-hoc child
7203            // using the inherited default system prompt, represented by an
7204            // omitted `agent_type`.
7205            if value.as_str() != Some("general-purpose") {
7206                translated.insert("agent_type".to_string(), value.clone());
7207            }
7208        }
7209        if let Some(value) = object.get("model") {
7210            translated.insert("model".to_string(), value.clone());
7211        }
7212        translated.insert(
7213            "background".to_string(),
7214            object
7215                .get("run_in_background")
7216                .cloned()
7217                .unwrap_or(serde_json::Value::Bool(true)),
7218        );
7219        Ok(crate::message::ToolCall {
7220            id: call.id.clone(),
7221            kind: call.kind.clone(),
7222            function: crate::message::FunctionCall {
7223                name: SPAWN_SUBAGENT.to_string(),
7224                arguments: serde_json::Value::Object(translated).to_string(),
7225            },
7226        })
7227    }
7228
7229    /// Execute Claude's scheduling vocabulary against the imported manifest.
7230    ///
7231    /// This is intentionally a state editor, not a scheduler: it owns no
7232    /// timer/task handle, nothing downstream of it fires, and every
7233    /// successful response says so, so the model is never told a job it just
7234    /// created will run here.
7235    fn run_claude_runtime_tool(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7236        let args = match call.function.parsed_arguments() {
7237            Ok(value) if value.is_object() => value,
7238            Ok(_) => {
7239                return (
7240                    format!("Error: {} arguments must be an object", call.function.name),
7241                    true,
7242                )
7243            }
7244            Err(error) => return (format!("Error: {error}"), true),
7245        };
7246        let object = args.as_object().expect("checked object above");
7247
7248        let Some(manifest) = self.claude_runtime_manifest.as_mut() else {
7249            return (
7250                "Error: Claude runtime compatibility was enabled without an imported runtime \
7251                 manifest; refusing to invent scheduler state"
7252                    .to_string(),
7253                true,
7254            );
7255        };
7256        // Every imported schedule is carried and inert. `state` is the single
7257        // fact the model is told about it, in the manifest's own vocabulary.
7258        let state = "paused";
7259
7260        match call.function.name.as_str() {
7261            CLAUDE_CRON_LIST => {
7262                let jobs: Vec<serde_json::Value> = manifest
7263                    .active_crons
7264                    .iter()
7265                    .map(|job| {
7266                        serde_json::json!({
7267                            "id": job.id,
7268                            "cron": job.schedule,
7269                            "prompt": job.prompt,
7270                            "recurring": job.recurring,
7271                            "durable": job.durable_requested,
7272                            "state": state
7273                        })
7274                    })
7275                    .collect();
7276                let notice = "Imported jobs are preserved but no scheduler is running.";
7277                (
7278                    serde_json::json!({
7279                        "execution_state": state,
7280                        "execution_notice": notice,
7281                        "jobs": jobs
7282                    })
7283                    .to_string(),
7284                    false,
7285                )
7286            }
7287            CLAUDE_CRON_CREATE => {
7288                let Some(schedule) = object.get("cron").and_then(serde_json::Value::as_str) else {
7289                    return ("Error: CronCreate requires string `cron`".to_string(), true);
7290                };
7291                let Some(prompt) = object.get("prompt").and_then(serde_json::Value::as_str) else {
7292                    return (
7293                        "Error: CronCreate requires string `prompt`".to_string(),
7294                        true,
7295                    );
7296                };
7297                let recurring = object
7298                    .get("recurring")
7299                    .and_then(serde_json::Value::as_bool)
7300                    .unwrap_or(false);
7301                let durable_requested = object
7302                    .get("durable")
7303                    .and_then(serde_json::Value::as_bool)
7304                    .unwrap_or(false);
7305                let mut sequence = 1_u64;
7306                let id = loop {
7307                    let candidate = format!("sc{sequence:06}");
7308                    if !manifest.active_crons.iter().any(|job| job.id == candidate) {
7309                        break candidate;
7310                    }
7311                    sequence += 1;
7312                };
7313                let kind = if recurring { "recurring " } else { "" };
7314                let result = format!(
7315                    "Scheduled {kind}job {id} ({schedule}) in PAUSED state. The job is preserved \
7316                     in the continuation manifest but no scheduler is running and it will not execute."
7317                );
7318                manifest
7319                    .active_crons
7320                    .push(crate::claude_runtime_state::ClaudeCronJob {
7321                        id: id.clone(),
7322                        tool_use_id: call.id.clone(),
7323                        schedule: schedule.to_string(),
7324                        recurring,
7325                        durable_requested,
7326                        prompt: prompt.to_string(),
7327                        // The creation instant is a fact about this
7328                        // continuation, recorded like every other manifest
7329                        // field. Nothing consults it as a due time.
7330                        created_at: Some(crate::sidecar::ms_to_rfc3339(now_ms())),
7331                        expires_after_seconds: None,
7332                        creation_result: result.clone(),
7333                    });
7334                manifest
7335                    .active_crons
7336                    .sort_by(|left, right| left.id.cmp(&right.id));
7337                (result, false)
7338            }
7339            CLAUDE_CRON_DELETE => {
7340                let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
7341                    return ("Error: CronDelete requires string `id`".to_string(), true);
7342                };
7343                let Some(index) = manifest.active_crons.iter().position(|job| job.id == id) else {
7344                    return (
7345                        format!("Error: unknown {state} Claude cron job `{id}`"),
7346                        true,
7347                    );
7348                };
7349                manifest.active_crons.remove(index);
7350                (
7351                    format!("Cancelled job {id}. The job was PAUSED; no execution occurred."),
7352                    false,
7353                )
7354            }
7355            CLAUDE_SCHEDULE_WAKEUP => {
7356                let Some(delay_seconds) = object
7357                    .get("delaySeconds")
7358                    .and_then(serde_json::Value::as_u64)
7359                else {
7360                    return (
7361                        "Error: ScheduleWakeup requires integer `delaySeconds`".to_string(),
7362                        true,
7363                    );
7364                };
7365                let reason = object
7366                    .get("reason")
7367                    .and_then(serde_json::Value::as_str)
7368                    .map(str::to_string);
7369                let prompt = object
7370                    .get("prompt")
7371                    .and_then(serde_json::Value::as_str)
7372                    .map(str::to_string);
7373                let now = now_ms();
7374                let created_at = Some(crate::sidecar::ms_to_rfc3339(now));
7375                // The instant the wakeup asks for, recorded as the request
7376                // made it. No timer consults it here.
7377                let delay_ms = i64::try_from(delay_seconds)
7378                    .unwrap_or(i64::MAX)
7379                    .saturating_mul(1_000);
7380                let scheduled_for = crate::sidecar::ms_to_rfc3339(now.saturating_add(delay_ms));
7381                let result = format!(
7382                    "Next wakeup recorded for {scheduled_for} (in {delay_seconds}s) in PAUSED \
7383                     state. The request replaced the prior wakeup in the manifest, but no timer \
7384                     is running and it will not execute."
7385                );
7386                manifest.pending_wakeups.clear();
7387                manifest
7388                    .pending_wakeups
7389                    .push(crate::claude_runtime_state::ClaudeWakeup {
7390                        tool_use_id: call.id.clone(),
7391                        delay_seconds,
7392                        reason,
7393                        prompt,
7394                        created_at,
7395                        scheduled_for: Some(scheduled_for),
7396                        creation_result: result.clone(),
7397                    });
7398                (result, false)
7399            }
7400            _ => unreachable!("runtime tool dispatch is name-gated"),
7401        }
7402    }
7403
7404    /// The `subagent_status` schema (P5-3, D3 "background+resume").
7405    fn subagent_status_schema() -> ToolSchema {
7406        ToolSchema {
7407            name: SUBAGENT_STATUS.to_string(),
7408            description: "Check on (and, once finished, retrieve the result of) a background \
7409                subagent spawned via spawn_subagent with background=true. Pass the \
7410                `subagent_id` that spawn returned."
7411                .to_string(),
7412            parameters: serde_json::json!({
7413                "type": "object",
7414                "properties": {
7415                    "subagent_id": {
7416                        "type": "string",
7417                        "description": "The id `spawn_subagent` returned when this subagent \
7418                            was spawned."
7419                    }
7420                },
7421                "required": ["subagent_id"],
7422                "additionalProperties": false
7423            }),
7424        }
7425    }
7426
7427    /// BP-7: the `subagent_message` schema.
7428    fn subagent_message_schema() -> ToolSchema {
7429        ToolSchema {
7430            name: SUBAGENT_MESSAGE.to_string(),
7431            description: "Send a message to a background subagent that is STILL RUNNING.                 The message is delivered to that subagent at the start of its next step,                 without interrupting the step it is on. Use `subagent_status` to check                 whether it is still running and to collect its result."
7432                .to_string(),
7433            parameters: serde_json::json!({
7434                "type": "object",
7435                "properties": {
7436                    "subagent_id": {
7437                        "type": "string",
7438                        "description": "The id `spawn_subagent` returned."
7439                    },
7440                    "message": {
7441                        "type": "string",
7442                        "description": "What to tell the running subagent."
7443                    }
7444                },
7445                "required": ["subagent_id", "message"],
7446                "additionalProperties": false
7447            }),
7448        }
7449    }
7450
7451    /// BP-7: the `subagent_resume` schema.
7452    fn subagent_resume_schema() -> ToolSchema {
7453        ToolSchema {
7454            name: SUBAGENT_RESUME.to_string(),
7455            description: "Continue a subagent that has already FINISHED, with its own                 previous conversation restored, so it keeps everything it learned instead                 of being briefed again from scratch. Pass the id it was spawned with and                 the next task."
7456                .to_string(),
7457            parameters: serde_json::json!({
7458                "type": "object",
7459                "properties": {
7460                    "subagent_id": {
7461                        "type": "string",
7462                        "description": "The id of a subagent that has already finished."
7463                    },
7464                    "task": {
7465                        "type": "string",
7466                        "description": "What the resumed subagent should do next."
7467                    }
7468                },
7469                "required": ["subagent_id", "task"],
7470                "additionalProperties": false
7471            }),
7472        }
7473    }
7474
7475    /// BP-7 (catalog §4a "Background subagents + resume"): deliver a
7476    /// message into a still-running background child's mailbox.
7477    ///
7478    /// The mailbox is the child's own `SteerInbox` — the seam P4b built for
7479    /// mid-turn steering, which is writable while the child's turn holds
7480    /// `&mut Agent`. So delivery ordering is already defined: the message
7481    /// arrives at the top of the child's next loop iteration, i.e. after
7482    /// whatever tool calls it is currently running, per its
7483    /// `steering_mode`. A child that has already FINISHED is refused with
7484    /// a pointer at `subagent_resume`, which is the operation for that
7485    /// case — never silently dropped.
7486    fn run_subagent_message(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7487        let args = match call.function.parsed_arguments() {
7488            Ok(v) => v,
7489            Err(e) => {
7490                let err = Error::InvalidArguments {
7491                    tool: SUBAGENT_MESSAGE.to_string(),
7492                    message: e.to_string(),
7493                };
7494                return (format!("Error: {err}"), true);
7495            }
7496        };
7497        let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
7498            let err = Error::InvalidArguments {
7499                tool: SUBAGENT_MESSAGE.to_string(),
7500                message: "`subagent_id` is required".to_string(),
7501            };
7502            return (format!("Error: {err}"), true);
7503        };
7504        let message = args
7505            .get("message")
7506            .and_then(serde_json::Value::as_str)
7507            .unwrap_or("");
7508        if message.is_empty() {
7509            let err = Error::InvalidArguments {
7510                tool: SUBAGENT_MESSAGE.to_string(),
7511                message: "`message` is required and must be non-empty".to_string(),
7512            };
7513            return (format!("Error: {err}"), true);
7514        }
7515        let Some(entry) = self.background_subagents.get(id) else {
7516            let err = Error::SubagentNotFound(id.to_string());
7517            return (format!("Error: {err}"), true);
7518        };
7519        if entry.handle.is_finished() {
7520            let out = serde_json::json!({
7521                "subagent_id": id,
7522                "status": "finished",
7523                "delivered": false,
7524                "hint": "this subagent already finished — collect it with subagent_status,                          then continue it with subagent_resume",
7525            });
7526            return (out.to_string(), false);
7527        }
7528        entry
7529            .mailbox
7530            .lock()
7531            .unwrap_or_else(std::sync::PoisonError::into_inner)
7532            .queue_unchecked(message.to_string());
7533        let out = serde_json::json!({
7534            "subagent_id": id,
7535            "status": "running",
7536            "delivered": true,
7537        });
7538        (out.to_string(), false)
7539    }
7540
7541    /// BP-7 (catalog §4a "Background subagents + resume": "resumable with
7542    /// context intact"): continue a finished child over its OWN transcript.
7543    ///
7544    /// The context comes from the reap (kept in-process) or, for a session
7545    /// that attached a subagent store, from that child's persisted
7546    /// `<parent>.subagents/<id>.sidecar.jsonl`. Either way the resumed
7547    /// child is rebuilt through `build_child_config` from the SAME named
7548    /// definition it was spawned with, so its permission posture on resume
7549    /// is the one it had originally — never a fresh, looser default.
7550    async fn run_subagent_resume(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7551        let args = match call.function.parsed_arguments() {
7552            Ok(v) => v,
7553            Err(e) => {
7554                let err = Error::InvalidArguments {
7555                    tool: SUBAGENT_RESUME.to_string(),
7556                    message: e.to_string(),
7557                };
7558                return (format!("Error: {err}"), true);
7559            }
7560        };
7561        let Some(id) = args
7562            .get("subagent_id")
7563            .and_then(serde_json::Value::as_str)
7564            .map(String::from)
7565        else {
7566            let err = Error::InvalidArguments {
7567                tool: SUBAGENT_RESUME.to_string(),
7568                message: "`subagent_id` is required".to_string(),
7569            };
7570            return (format!("Error: {err}"), true);
7571        };
7572        let task = args
7573            .get("task")
7574            .and_then(serde_json::Value::as_str)
7575            .unwrap_or("")
7576            .to_string();
7577        if task.is_empty() {
7578            let err = Error::InvalidArguments {
7579                tool: SUBAGENT_RESUME.to_string(),
7580                message: "`task` is required and must be non-empty".to_string(),
7581            };
7582            return (format!("Error: {err}"), true);
7583        }
7584        if self
7585            .background_subagents
7586            .get(&id)
7587            .is_some_and(|e| !e.handle.is_finished())
7588        {
7589            let err = Error::tool(
7590                SUBAGENT_RESUME,
7591                format!(
7592                    "subagent `{id}` is still running — send it a message with                      subagent_message, or collect it with subagent_status first"
7593                ),
7594            );
7595            return (format!("Error: {err}"), true);
7596        }
7597        let lineage = self
7598            .subagent_store
7599            .as_ref()
7600            .and_then(|(store, parent)| store.load_subagent_lineage(parent, &id).ok().flatten());
7601        let Some(prior) = self.prior_subagent_transcript(&id) else {
7602            let err = Error::SubagentNotFound(id.clone());
7603            return (format!("Error: {err}"), true);
7604        };
7605
7606        let agent_type = lineage.as_ref().and_then(|l| l.agent_type.clone());
7607        let definition = agent_type
7608            .as_ref()
7609            .and_then(|name| self.config.subagents_definitions.get(name).cloned());
7610        let Some(guard) = crate::subagents::try_acquire(
7611            &self.subagent_concurrency_gauge,
7612            self.config.subagents_max_concurrent,
7613        ) else {
7614            let err = Error::SubagentConcurrencyExceeded {
7615                max_concurrent: self.config.subagents_max_concurrent,
7616            };
7617            return (format!("Error: {err}"), true);
7618        };
7619        let child_config = self.build_child_config(
7620            definition.as_ref(),
7621            None,
7622            lineage
7623                .as_ref()
7624                .map(|l| l.model.clone())
7625                .or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
7626        );
7627        let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
7628        child.subagent_depth = self.subagent_depth + 1;
7629        child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
7630        // Context intact: the child's own prior messages, appended after
7631        // its (re-derived, identical) system prompt.
7632        child.history.extend(prior);
7633
7634        let result = child.send(task).await;
7635        let transcript = child.history()[1..].to_vec();
7636        if let Some(lineage) = &lineage {
7637            self.persist_subagent_transcript(&id, lineage, &transcript);
7638        }
7639        self.reaped_subagents.insert(id.clone(), transcript);
7640        drop(guard);
7641        match result {
7642            Ok(text) => {
7643                let out = serde_json::json!({
7644                    "subagent_id": id,
7645                    "status": "done",
7646                    "resumed": true,
7647                    "result": text,
7648                });
7649                (out.to_string(), false)
7650            }
7651            Err(e) => {
7652                let out = serde_json::json!({
7653                    "subagent_id": id,
7654                    "status": "error",
7655                    "resumed": true,
7656                    "message": e.to_string(),
7657                });
7658                (out.to_string(), true)
7659            }
7660        }
7661    }
7662
7663    /// BP-7 (catalog §4a "Named agent definitions as data"): the child
7664    /// `Config` a `spawn_subagent` of `agent_type` would build — the
7665    /// resolved posture a named definition actually produces, including
7666    /// its [`crate::subagents::AgentPermissions`] bundle applied through
7667    /// the tightening-only rules. `None` when no definition of that name
7668    /// is registered or discovered.
7669    ///
7670    /// Exposed so a caller (and this build's tests) can ask what a named
7671    /// agent WOULD run as without spawning it and paying for a turn.
7672    pub fn child_config_for_agent_type(&self, agent_type: &str) -> Option<Config> {
7673        let definition = self.config.subagents_definitions.get(agent_type)?.clone();
7674        Some(self.build_child_config(Some(&definition), None, definition.model.clone()))
7675    }
7676
7677    /// BP-7 (catalog §4a "Background subagents + resume"): the ids of
7678    /// children that have finished and been reaped, and can therefore be
7679    /// continued with [`SUBAGENT_RESUME`].
7680    pub fn reaped_subagent_ids(&self) -> Vec<String> {
7681        let mut ids: Vec<String> = self.reaped_subagents.keys().cloned().collect();
7682        ids.sort();
7683        ids
7684    }
7685
7686    /// BP-7: a finished child's own messages — from the in-process reap
7687    /// cache first, then this session's subagent store.
7688    fn prior_subagent_transcript(&self, id: &str) -> Option<Vec<ChatMessage>> {
7689        if let Some(messages) = self.reaped_subagents.get(id) {
7690            return Some(messages.clone());
7691        }
7692        let (store, parent) = self.subagent_store.as_ref()?;
7693        let jsonl = store.load_subagent_transcript(parent, id).ok()??;
7694        let session = crate::session::Session::from_sidecar_str(&jsonl).ok()?;
7695        Some(
7696            session
7697                .messages
7698                .into_iter()
7699                .filter(|m| m.role != crate::message::Role::System)
7700                .collect(),
7701        )
7702    }
7703
7704    /// Build the CHILD `Config` a `spawn_subagent` call constructs its
7705    /// [`Agent`] from. The whole point of this method (§5.3-style
7706    /// "monotonic posture", build-brief "a subagent inherits or narrows —
7707    /// never widens — the parent's permission posture"): every field that
7708    /// governs what the child is ALLOWED to do (sandbox, approval,
7709    /// tool_overrides, deny/allow patterns, protected paths, the subagents
7710    /// caps themselves) is copied VERBATIM from `self.config` — never
7711    /// loosened — and the only NARROWING lever is `definition.tools`
7712    /// (intersected with whatever the parent already had enabled, never
7713    /// unioned in anything new).
7714    ///
7715    /// P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child safety-limit
7716    /// inheritance"): the monotonic-posture guarantee above was, before this
7717    /// fix, scoped to PERMISSION fields only — a child could still silently
7718    /// get a LOOSER safety BUDGET/BREAKER than its parent, because
7719    /// `max_total_output_tokens`/`max_tool_output_bytes`/`max_tokens`/
7720    /// `doom_loop_threshold`/`edit_file_require_read_before_edit` were never
7721    /// copied and so fell back to `Config::default()`'s (looser/uncapped)
7722    /// values on every spawn regardless of what the parent had configured.
7723    /// These are now copied verbatim alongside the permission-posture
7724    /// fields — a parent that capped its own output/tool-output/doom-loop
7725    /// exposure, or required read-before-edit, gets a child that is bound
7726    /// by the exact same ceiling, never a wider one.
7727    ///
7728    /// **Full field-by-field accounting** (every [`Config`] field, so this
7729    /// doc comment stays the single place that answers "did we forget
7730    /// one?"): fields already copied above/below this note (permission
7731    /// posture: `sandbox`/`approval`/`tool_overrides`/`auto_approved_tools`/
7732    /// `tool_deny_patterns`/`tool_allow_patterns`/`permissions_enabled`/
7733    /// `permissions_ask_patterns`/`permissions_protected_paths`/
7734    /// `network_policy`/`core_tools_enabled`/`module_registry`/
7735    /// `module_activation`/every `subagents_*` field; safety limits:
7736    /// `max_iterations`/`max_total_output_tokens`/`max_tool_output_bytes`/
7737    /// `max_tokens`/`doom_loop_threshold`/`edit_file_require_read_before_edit`;
7738    /// identity/transport: `model`/`system_prompt`/`cwd`/`base_url`/
7739    /// `api_key`/`api_key_env`/`api_key_cmd`) are the ones that gate
7740    /// harm/spend/hazard exposure. Every OTHER field is deliberately left at
7741    /// `Config::default()` because none of them is a safety ceiling the
7742    /// child could "loosen" by missing it:
7743    /// - `temperature`/`effort`/`response_format`/`extra_body`/`extra_headers`/
7744    ///   `tool_advertising`/`tool_schema_tier`/`cache_plan`/`cache_warnings`/
7745    ///   `reduction_policy`/
7746    ///   `session_*`/`small_model`/`model_fallback`/`env_context`/
7747    ///   `project_root_markers`/`project_doc_max_bytes`/`instruction_imports`/
7748    ///   `retry_*`/`compaction_*`/`auto_title`/`steering_mode`/
7749    ///   `follow_up_mode`/`read_file_multimodal`/`edit_file_notebook_aware`/
7750    ///   `shell_env_snapshot`/`nested_instructions`/`model_switch_allow_switch`/
7751    ///   `context_injections`/`context_injection_blocks`/`parallel_tool_calls`
7752    ///   are behavior/cost-shaping or presentation knobs, not hard guards —
7753    ///   a child defaulting on any of these can do LESS (e.g. no multimodal
7754    ///   read, no notebook-aware edits, no proactive compaction) or the same,
7755    ///   never something the parent hadn't already exposed it to. Several
7756    ///   default to their OFF/conservative state (`false`/`None`), which is
7757    ///   the tight direction, not the loose one.
7758    /// - `additional_dirs`: governs which extra roots are reachable at all
7759    ///   (`presets.rs`'s `[core] additional_dirs` note) — a child that
7760    ///   doesn't inherit it has FEWER reachable roots than its parent, i.e.
7761    ///   strictly tighter, never looser.
7762    /// - `load_project_context`: whether instruction files are auto-loaded
7763    ///   into the system prompt — a read-time convenience, not an access
7764    ///   grant (`sandbox`/`permissions_protected_paths` already gate actual
7765    ///   file access).
7766    /// - `prompts`: named `/slash` command templates for THIS agent's own
7767    ///   user-facing input surface, not something the model can invoke
7768    ///   against the child's tool surface.
7769    /// - `stop_gate`/`post_tool_hook`/`approval_handler`/`event_sink`:
7770    ///   code-only `Box<dyn Fn>` callbacks (see the `pre_tool_hook` note
7771    ///   immediately below — same non-`Clone` shape) that are observational
7772    ///   or terminate-only, not a call-time veto over what a tool is allowed
7773    ///   to do; `approval_handler` specifically is ALREADY documented at
7774    ///   this method's call site (`Self::run_spawn_subagent`) as
7775    ///   intentionally never set here — a foreground child gets no handler
7776    ///   by design, an embedder installs its own after spawn if it wants
7777    ///   one.
7778    ///
7779    /// **`pre_tool_hook` cannot propagate, and this is deliberate + named,
7780    /// not a silent gap**: `Config::pre_tool_hook` is a `Box<dyn Fn(&str,
7781    /// &serde_json::Value) -> Option<String> + Send + Sync>` — an
7782    /// embedder's own call-time veto over every tool call. `Box<dyn Fn>` is
7783    /// not `Clone` (there is no generic way to duplicate an opaque closure),
7784    /// so it genuinely CANNOT be copied into a child `Config` the way every
7785    /// `Clone`-able field above is — there is no fix that makes this one
7786    /// "verbatim copy" like the others. An embedder relying on a
7787    /// `pre_tool_hook` veto reaching spawned children as well as the parent
7788    /// MUST re-install one on the child explicitly (e.g. via a
7789    /// `spawn_subagent`-adjacent hook of their own, or by not relying on
7790    /// `pre_tool_hook` alone for anything safety-critical across a spawn
7791    /// boundary) — named here so this is a documented contract, not a gap
7792    /// an embedder discovers by a child silently misbehaving.
7793    fn build_child_config(
7794        &self,
7795        definition: Option<&crate::subagents::NamedAgentDefinition>,
7796        inline_system_prompt: Option<String>,
7797        model_override: Option<String>,
7798    ) -> Config {
7799        let system_prompt = definition
7800            .map(|d| d.system_prompt.clone())
7801            .filter(|s| !s.is_empty())
7802            .or(inline_system_prompt)
7803            .unwrap_or_else(|| self.config.system_prompt.clone());
7804        let model = model_override.unwrap_or_else(|| self.config.model.clone());
7805
7806        let mut child = Config::builder()
7807            .model(model)
7808            .system_prompt(system_prompt)
7809            .cwd(self.config.cwd.clone())
7810            // Monotonic: verbatim, never loosened.
7811            .sandbox(self.config.sandbox)
7812            .approval(self.config.approval)
7813            .max_iterations(self.config.max_iterations)
7814            .build();
7815        child.base_url = self.config.base_url.clone();
7816        child.api_key = self.config.api_key.clone();
7817        child.api_key_env = self.config.api_key_env.clone();
7818        child.api_key_cmd = self.config.api_key_cmd.clone();
7819        // P5-3 safety hardening (Fable-5 review, LOW-MEDIUM "child
7820        // safety-limit inheritance"): the monotonic-posture spirit extends
7821        // to safety BUDGETS/BREAKERS, not just permissions — a child must
7822        // not get a looser cap/breaker than its parent by simply falling
7823        // back to `Config::default()`'s (looser) values. See this method's
7824        // doc comment for the full field-by-field accounting.
7825        child.max_total_output_tokens = self.config.max_total_output_tokens;
7826        child.max_tool_output_bytes = self.config.max_tool_output_bytes;
7827        child.max_tokens = self.config.max_tokens;
7828        child.doom_loop_threshold = self.config.doom_loop_threshold;
7829        child.edit_file_require_read_before_edit = self.config.edit_file_require_read_before_edit;
7830        // Monotonic tool posture: start from the PARENT's own overrides
7831        // (so anything the parent already disabled stays disabled), then
7832        // narrow further if a named definition restricts the tool set.
7833        child.tool_overrides = self.config.tool_overrides.clone();
7834        child.auto_approved_tools = self.config.auto_approved_tools.clone();
7835        child.tool_deny_patterns = self.config.tool_deny_patterns.clone();
7836        child.tool_allow_patterns = self.config.tool_allow_patterns.clone();
7837        child.permissions_enabled = self.config.permissions_enabled;
7838        child.permissions_ask_patterns = self.config.permissions_ask_patterns.clone();
7839        child.permissions_protected_paths = self.config.permissions_protected_paths.clone();
7840        child.network_policy = self.config.network_policy.clone();
7841        // P5-10 (§2 module 12): same monotonic-posture treatment as
7842        // `sandbox`/`approval` above — a subagent must inherit its
7843        // parent's OS-sandbox posture verbatim, never a looser
7844        // `Config::default()` fallback (`sandbox_os_enabled: None`,
7845        // `escalation: Deny`, `env_policy: Inherit` would otherwise be
7846        // right back to "confine only when the tier itself says so" for a
7847        // child whose parent explicitly forced the backstop on/off).
7848        child.sandbox_os_enabled = self.config.sandbox_os_enabled;
7849        child.sandbox_escalation = self.config.sandbox_escalation;
7850        child.sandbox_env_policy = self.config.sandbox_env_policy;
7851        if let Some(def) = definition {
7852            if let Some(allowed) = &def.tools {
7853                for name in &self.config.core_tools_enabled {
7854                    if !allowed.iter().any(|t| t == name) {
7855                        child
7856                            .tool_overrides
7857                            .entry(name.clone())
7858                            .or_default()
7859                            .enabled = Some(false);
7860                    }
7861                }
7862            }
7863            // BP-7 (catalog §4a "Named agent definitions as data": the
7864            // `permissions` component of `prompt+model+tools+permissions`).
7865            // Every arm below can only TIGHTEN — the two policy values go
7866            // through the SAME strictness ranks `configfile::
7867            // clamp_project_permissions` uses for the untrusted project
7868            // layer (a looser value is ignored, never honored), the
7869            // auto-approve list is INTERSECTED with the parent's, and the
7870            // deny list is a union. A definition may come from a
7871            // `.claude/agents/*.md` file in the repo, so it sits at the
7872            // project trust tier and must never be an escalation door.
7873            if let Some(perms) = &def.permissions {
7874                if let Some(approval) = perms.approval {
7875                    if crate::configfile::approval_rank(approval)
7876                        < crate::configfile::approval_rank(child.approval)
7877                    {
7878                        child.approval = approval;
7879                    }
7880                }
7881                if let Some(sandbox) = perms.sandbox {
7882                    if crate::configfile::sandbox_rank(sandbox)
7883                        < crate::configfile::sandbox_rank(child.sandbox)
7884                    {
7885                        child.sandbox = sandbox;
7886                    }
7887                }
7888                if let Some(allowed) = &perms.auto_approved_tools {
7889                    child
7890                        .auto_approved_tools
7891                        .retain(|tool| allowed.iter().any(|a| a == tool));
7892                }
7893                for pattern in &perms.deny {
7894                    if !child.tool_deny_patterns.iter().any(|p| p == pattern) {
7895                        child.tool_deny_patterns.push(pattern.clone());
7896                    }
7897                }
7898            }
7899        }
7900        child.core_tools_enabled = self.config.core_tools_enabled.clone();
7901        child.module_registry = self.config.module_registry;
7902        child.module_activation = self.config.module_activation.clone();
7903        // The subagents module itself never widens either: a child spawned
7904        // at depth d+1 inherits the SAME caps (never a looser depth/
7905        // concurrency/background posture than its own parent).
7906        child.subagents_enabled = self.config.subagents_enabled;
7907        child.subagents_max_depth = self.config.subagents_max_depth;
7908        child.subagents_max_concurrent = self.config.subagents_max_concurrent;
7909        child.subagents_background = self.config.subagents_background;
7910        child.subagents_background_prompts = self.config.subagents_background_prompts;
7911        child.subagents_claude_agent_alias = self.config.subagents_claude_agent_alias;
7912        child.subagents_definitions = self.config.subagents_definitions.clone();
7913        child.subagent_depth = self.subagent_depth + 1;
7914        child
7915    }
7916
7917    /// Execute the `spawn_subagent` intrinsic (P5-3, §2 module 9). See
7918    /// `Self::build_child_config` for the monotonic-posture guarantee and
7919    /// `crate::subagents` for the depth/concurrency resource bounds and the
7920    /// §2.2 C6 background-policy enforcement.
7921    async fn run_spawn_subagent(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
7922        // BP-11: `subagent_start`/`subagent_stop` bracket a call that passed
7923        // the same validation the runner applies (subagents on, non-empty
7924        // task); a refused call fires neither.
7925        let task = if self.config.subagents_enabled {
7926            call.function
7927                .parsed_arguments()
7928                .ok()
7929                .and_then(|v| {
7930                    v.get("task")
7931                        .and_then(serde_json::Value::as_str)
7932                        .map(str::to_string)
7933                })
7934                .filter(|t| !t.is_empty())
7935        } else {
7936            None
7937        };
7938        if let Some(task) = &task {
7939            self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStart {
7940                task: task.clone(),
7941            });
7942        }
7943        let (output, is_error) = self.run_spawn_subagent_inner(call).await;
7944        if let Some(task) = task {
7945            self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStop {
7946                task,
7947                is_error,
7948                output_len: output.len(),
7949            });
7950        }
7951        (output, is_error)
7952    }
7953
7954    /// Hands a lifecycle moment to the installed observer, if any (BP-11).
7955    fn fire_lifecycle(&self, event: &crate::config::LifecycleEvent) {
7956        if let Some(hook) = self.config.lifecycle_hook.as_ref() {
7957            hook(event);
7958        }
7959    }
7960
7961    /// Installs the lifecycle observer (compaction and subagent boundaries).
7962    pub fn set_lifecycle_hook(&mut self, hook: crate::config::LifecycleHook) {
7963        self.config.lifecycle_hook = Some(hook);
7964    }
7965
7966    async fn run_spawn_subagent_inner(
7967        &mut self,
7968        call: &crate::message::ToolCall,
7969    ) -> (String, bool) {
7970        if !self.config.subagents_enabled {
7971            let err = Error::UnknownTool(SPAWN_SUBAGENT.to_string());
7972            return (format!("Error: {err}"), true);
7973        }
7974        let args = match call.function.parsed_arguments() {
7975            Ok(v) => v,
7976            Err(e) => {
7977                let err = Error::InvalidArguments {
7978                    tool: SPAWN_SUBAGENT.to_string(),
7979                    message: e.to_string(),
7980                };
7981                return (format!("Error: {err}"), true);
7982            }
7983        };
7984        let task = args
7985            .get("task")
7986            .and_then(serde_json::Value::as_str)
7987            .unwrap_or("")
7988            .to_string();
7989        if task.is_empty() {
7990            let err = Error::InvalidArguments {
7991                tool: SPAWN_SUBAGENT.to_string(),
7992                message: "`task` is required and must be non-empty".to_string(),
7993            };
7994            return (format!("Error: {err}"), true);
7995        }
7996        let agent_type = args
7997            .get("agent_type")
7998            .and_then(serde_json::Value::as_str)
7999            .map(String::from);
8000        let inline_system_prompt = args
8001            .get("system_prompt")
8002            .and_then(serde_json::Value::as_str)
8003            .map(String::from);
8004        let background = args
8005            .get("background")
8006            .and_then(serde_json::Value::as_bool)
8007            .unwrap_or(false);
8008        let requested_model = args
8009            .get("model")
8010            .and_then(serde_json::Value::as_str)
8011            .map(|model| crate::model_catalog::resolve_alias(model));
8012
8013        let definition = match &agent_type {
8014            Some(name) => match self.config.subagents_definitions.get(name) {
8015                Some(d) => Some(d.clone()),
8016                None => {
8017                    let err = Error::SubagentDefinitionNotFound(name.clone());
8018                    return (format!("Error: {err}"), true);
8019                }
8020            },
8021            None => None,
8022        };
8023
8024        if background {
8025            if !self.config.subagents_background {
8026                let err = Error::tool(
8027                    SPAWN_SUBAGENT,
8028                    "background=true requires capabilities.subagents.background = true",
8029                );
8030                return (format!("Error: {err}"), true);
8031            }
8032            // §2.2 C6, defensive re-check (belt-and-suspenders — see
8033            // `Error::SubagentBackgroundPolicyMissing`'s doc comment for why
8034            // this can't just trust the resolver already checked it).
8035            if self.config.subagents_background_prompts.is_none() {
8036                let err = Error::SubagentBackgroundPolicyMissing;
8037                return (format!("Error: {err}"), true);
8038            }
8039        }
8040
8041        // Resource bounds (fail-closed): depth first (cheap, no side
8042        // effect on failure), THEN concurrency (holds a slot — must be the
8043        // LAST check before actually spawning, so a refused spawn never
8044        // leaves a stray slot held).
8045        if let Err(e) =
8046            crate::subagents::check_depth(self.subagent_depth, self.config.subagents_max_depth)
8047        {
8048            return (format!("Error: {e}"), true);
8049        }
8050        let Some(guard) = crate::subagents::try_acquire(
8051            &self.subagent_concurrency_gauge,
8052            self.config.subagents_max_concurrent,
8053        ) else {
8054            let err = Error::SubagentConcurrencyExceeded {
8055                max_concurrent: self.config.subagents_max_concurrent,
8056            };
8057            return (format!("Error: {err}"), true);
8058        };
8059
8060        let child_id = next_subagent_id();
8061        let child_config = self.build_child_config(
8062            definition.as_ref(),
8063            inline_system_prompt,
8064            requested_model.or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
8065        );
8066        let child_model = child_config.model.clone();
8067        let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
8068        child.subagent_depth = self.subagent_depth + 1;
8069        child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
8070
8071        // §2.2 C6: a background child NEVER gets a BLOCKING-BY-DEFAULT
8072        // interactive approval handler — either no handler at all
8073        // (`AutoPolicy`: the engine's pre-existing "no handler ⇒ deny"
8074        // fail-closed default), or (`Parent`) the never-blocking
8075        // `ParentQueueApprovalHandler`, UNLESS a `tui` embedder has
8076        // installed [`Self::child_approval_handler_factory`] (P5-4), in
8077        // which case THAT builds the handler instead — see
8078        // [`Self::set_child_approval_handler_factory`]'s doc comment for
8079        // why this can't escalate past what the rule engine already routed
8080        // to `Ask`. A foreground child also gets no handler here (today's
8081        // existing default posture; an embedder that wants an interactive
8082        // child installs its own via `set_permissions_approval_handler`
8083        // after this call returns, out of this method's scope).
8084        if background {
8085            if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
8086                self.config.subagents_background_prompts
8087            {
8088                let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
8089                    match &self.child_approval_handler_factory {
8090                        Some(factory) => {
8091                            factory(child_id.clone(), self.pending_child_approvals.clone())
8092                        }
8093                        None => std::sync::Arc::new(crate::subagents::ParentQueueApprovalHandler {
8094                            child_agent_id: child_id.clone(),
8095                            queue: self.pending_child_approvals.clone(),
8096                        }),
8097                    };
8098                child.ctx.sandbox_approval_handler =
8099                    Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
8100                child.permissions_approval_handler = Some(handler);
8101            }
8102        }
8103
8104        let lineage = crate::subagents::SubagentLineage {
8105            child_agent_id: child_id.clone(),
8106            parent_session_id: self.subagent_store.as_ref().map(|(_, name)| name.clone()),
8107            parent_tool_use_id: call.id.clone(),
8108            depth: self.subagent_depth + 1,
8109            agent_type: agent_type.clone(),
8110            task: task.clone(),
8111            background,
8112            spawned_at_ms: now_ms(),
8113            model: child_model,
8114        };
8115        if let Some((store, parent_name)) = &self.subagent_store {
8116            let _ = store.save_subagent_lineage(parent_name, &child_id, &lineage);
8117        }
8118
8119        if background {
8120            let spawned_task_text = task.clone();
8121            // BP-7: captured BEFORE `child` moves into the task — this is
8122            // the handle `subagent_message` writes into.
8123            let mailbox = child.steer_queue_handle();
8124            self.background_subagents.insert(
8125                child_id.clone(),
8126                BackgroundSubagent {
8127                    handle: tokio::spawn(async move {
8128                        // The concurrency slot lives for exactly as long as
8129                        // this future runs — moved in here, dropped when the
8130                        // child's `send` (and this future) finishes.
8131                        let _guard = guard;
8132                        let result = child.send(spawned_task_text).await;
8133                        let transcript = child.history()[1..].to_vec();
8134                        (child_id, result, transcript)
8135                    }),
8136                    task,
8137                    agent_type,
8138                    started_at_ms: lineage.spawned_at_ms,
8139                    mailbox,
8140                },
8141            );
8142            let out = serde_json::json!({
8143                "subagent_id": lineage.child_agent_id,
8144                "status": "spawned",
8145                "background": true,
8146            });
8147            return (out.to_string(), false);
8148        }
8149
8150        // Foreground: run to completion now, guard held until this
8151        // function returns (then drops, freeing the slot).
8152        let result = child.send(task).await;
8153        let transcript = child.history()[1..].to_vec();
8154        self.persist_subagent_transcript(&child_id, &lineage, &transcript);
8155        // BP-7: kept in-process so `subagent_resume` can restore this
8156        // child's context even with no session store attached.
8157        self.reaped_subagents
8158            .insert(child_id.clone(), transcript.clone());
8159        drop(guard);
8160        match result {
8161            Ok(text) => (text, false),
8162            Err(e) => (format!("Error: subagent `{child_id}` failed: {e}"), true),
8163        }
8164    }
8165
8166    /// Execute the `subagent_status` intrinsic (P5-3, D3
8167    /// "background+resume"): poll a background child; once its `JoinHandle`
8168    /// is finished, reap it (removing it from `Self::background_subagents`
8169    /// and persisting its transcript, same as the foreground path).
8170    async fn run_subagent_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8171        let args = match call.function.parsed_arguments() {
8172            Ok(v) => v,
8173            Err(e) => {
8174                let err = Error::InvalidArguments {
8175                    tool: SUBAGENT_STATUS.to_string(),
8176                    message: e.to_string(),
8177                };
8178                return (format!("Error: {err}"), true);
8179            }
8180        };
8181        let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
8182            let err = Error::InvalidArguments {
8183                tool: SUBAGENT_STATUS.to_string(),
8184                message: "`subagent_id` is required".to_string(),
8185            };
8186            return (format!("Error: {err}"), true);
8187        };
8188        let Some(entry) = self.background_subagents.get(id) else {
8189            let err = Error::SubagentNotFound(id.to_string());
8190            return (format!("Error: {err}"), true);
8191        };
8192        if !entry.handle.is_finished() {
8193            let out = serde_json::json!({
8194                "subagent_id": id,
8195                "status": "pending",
8196                "task": entry.task,
8197                "agent_type": entry.agent_type,
8198                "started_at_ms": entry.started_at_ms,
8199            });
8200            return (out.to_string(), false);
8201        }
8202        // Finished — reap it. `.await` on an already-finished handle
8203        // resolves immediately (never actually blocks).
8204        let entry = self
8205            .background_subagents
8206            .remove(id)
8207            .expect("checked Some above");
8208        let (child_id, result, transcript) = match entry.handle.await {
8209            Ok(v) => v,
8210            Err(join_err) => {
8211                let err = Error::tool(
8212                    SUBAGENT_STATUS,
8213                    format!("subagent `{id}` task panicked: {join_err}"),
8214                );
8215                return (format!("Error: {err}"), true);
8216            }
8217        };
8218        // Re-derive the lineage record for persistence (cheap; the fields
8219        // are all still in hand) — mirrors the foreground path's single
8220        // `persist_subagent_transcript` call site.
8221        if let Some((store, parent_name)) = self.subagent_store.clone() {
8222            if let Ok(Some(lineage)) = store.load_subagent_lineage(&parent_name, &child_id) {
8223                self.persist_subagent_transcript(&child_id, &lineage, &transcript);
8224            }
8225        }
8226        // BP-7: see the foreground path's identical line.
8227        self.reaped_subagents
8228            .insert(child_id.clone(), transcript.clone());
8229        match result {
8230            Ok(text) => {
8231                let out = serde_json::json!({
8232                    "subagent_id": child_id,
8233                    "status": "done",
8234                    "result": text,
8235                });
8236                (out.to_string(), false)
8237            }
8238            Err(e) => {
8239                let out = serde_json::json!({
8240                    "subagent_id": child_id,
8241                    "status": "error",
8242                    "message": e.to_string(),
8243                });
8244                (out.to_string(), true)
8245            }
8246        }
8247    }
8248
8249    /// Execute the `background_exec` intrinsic (P5-6, §2 module 4, D1
8250    /// "background exec"): spawn `args.command` as a detached OS process
8251    /// via `crate::tools::build_sandboxed_sh` — the SAME sandboxed-spawn
8252    /// path [`crate::tools::BashTool::execute`] uses — and return its job
8253    /// id IMMEDIATELY, never the command's output. Gated by the same
8254    /// permission check a foreground `bash` call gets
8255    /// ([`Self::background_permission_denial`]), then a fail-closed
8256    /// concurrency cap ([`Config::tools_background_max_concurrent`]), THEN
8257    /// the actual spawn — in that order, so a refused call never holds a
8258    /// concurrency slot and never touches the process table.
8259    fn run_background_exec(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8260        let args = match call.function.parsed_arguments() {
8261            Ok(v) => v,
8262            Err(e) => {
8263                let err = Error::InvalidArguments {
8264                    tool: BACKGROUND_EXEC.to_string(),
8265                    message: e.to_string(),
8266                };
8267                return (format!("Error: {err}"), true);
8268            }
8269        };
8270        let command = args
8271            .get("command")
8272            .and_then(serde_json::Value::as_str)
8273            .unwrap_or("")
8274            .to_string();
8275        if command.is_empty() {
8276            let err = Error::InvalidArguments {
8277                tool: BACKGROUND_EXEC.to_string(),
8278                message: "`command` is required and must be non-empty".to_string(),
8279            };
8280            return (format!("Error: {err}"), true);
8281        }
8282
8283        // A job id up front (before spawning) — used both as the audit
8284        // handle for a §2.2 C6 `Parent`-policy queued denial (this call may
8285        // never actually reach the spawn below) and, if the call proceeds,
8286        // as `Self::background_jobs`'s real key.
8287        let job_id = crate::background::next_job_id(now_ms());
8288
8289        // Fable-5 review (LOW, "pre_tool_hook + doom-loop don't cover
8290        // background_exec"): this intrinsic is intercepted in
8291        // `Self::prepare_tool_call` and returns before `Self::finish_prepare`
8292        // ever runs, so — unlike a foreground `bash` call — it was reaching
8293        // this real spawn below WITHOUT ever offering `Config.pre_tool_hook`
8294        // a chance to veto it. `background_exec` runs a REAL command (unlike
8295        // the purely in-process meta-intrinsics `tool_search`/
8296        // `expand_reduction`/`sidecar_search`, which have no such gap to
8297        // close), so it belongs behind the same security-relevant veto a
8298        // foreground call gets. Scoped to this one call site — the other
8299        // meta-intrinsics are unchanged. The doom-loop counter
8300        // (`Self::check_doom_loop`) is deliberately NOT wired here: it is a
8301        // foreground repetition breaker keyed on `(self.doom_loop_last_call,
8302        // self.doom_loop_streak)`, a single piece of state shared with the
8303        // ordinary tool-call loop — folding background jobs into that same
8304        // streak would make an interleaved foreground/background pattern
8305        // trip (or fail to trip) the breaker in ways that have nothing to
8306        // do with the foreground loop actually repeating itself; the
8307        // pre_tool_hook veto below is the security-relevant half of this
8308        // fix, the doom-loop breaker is not.
8309        // BP-10: the hook now runs BEFORE this path's permissions gate, the
8310        // same order the foreground path uses — so a rewrite is what the
8311        // rules evaluate and what actually runs, and the hook's
8312        // `Allow`/`Ask` are tiers inside the engine rather than a second
8313        // verdict beside it.
8314        let mut command = command;
8315        let mut hook_decision = crate::config::HookDecision::Pass;
8316        if let Some(hook) = &self.config.pre_tool_hook {
8317            let outcome = hook(BACKGROUND_EXEC, &args);
8318            if outcome.decision == crate::config::HookDecision::Deny {
8319                let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
8320                return (format!("Error: blocked by pre-tool hook: {reason}"), true);
8321            }
8322            if let Some(rewritten) = outcome.updated_args {
8323                command = rewritten
8324                    .get("command")
8325                    .and_then(|v| v.as_str())
8326                    .unwrap_or(&command)
8327                    .to_string();
8328            }
8329            hook_decision = outcome.decision;
8330        }
8331
8332        if let Some(reason) = self.background_permission_denial(&command, &job_id, hook_decision) {
8333            return (format!("Error: {reason}"), true);
8334        }
8335
8336        let Some(guard) = crate::subagents::try_acquire(
8337            &self.background_concurrency_gauge,
8338            self.config.tools_background_max_concurrent,
8339        ) else {
8340            let err = Error::BackgroundJobConcurrencyExceeded {
8341                max_concurrent: self.config.tools_background_max_concurrent,
8342            };
8343            return (format!("Error: {err}"), true);
8344        };
8345
8346        let mut cmd = match crate::tools::build_sandboxed_sh(&command, &self.ctx) {
8347            Ok(cmd) => cmd,
8348            Err(e) => return (format!("Error: {e}"), true),
8349        };
8350        cmd.current_dir(&self.ctx.cwd)
8351            .stdin(std::process::Stdio::null())
8352            .stdout(std::process::Stdio::piped())
8353            .stderr(std::process::Stdio::piped())
8354            // Defense-in-depth for the "must be killed on drop" guarantee —
8355            // see `impl Drop for Agent`'s doc comment; the EXPLICIT
8356            // `start_kill()` loop there is what makes the guarantee
8357            // provable, this is a second, independent line of defense for
8358            // the same outcome.
8359            .kill_on_drop(true);
8360        // Fable-5 review (HIGH, "grandchildren orphaned on kill AND
8361        // agent-drop"): `Child::start_kill` only signals the DIRECT child.
8362        // A background command that spawns a surviving subprocess (a `&`
8363        // job, a pipeline, a double-forking daemon — or, on macOS, the
8364        // `sandbox-exec` wrapper itself in `build_sandboxed_sh`, whose real
8365        // `sh` and ITS children are all grandchildren of the tracked pid)
8366        // leaves those processes running, reparented to init, after the
8367        // tracked job is "killed". Putting this job in its OWN new process
8368        // group (`pgid == its own pid`, since every descendant inherits the
8369        // group unless it explicitly opts out) lets `kill_job_process_group`
8370        // below signal the WHOLE tree at kill/drop time, not just the one
8371        // pid we happen to be tracking. No portable equivalent on Windows —
8372        // see `kill_job_process_group`'s `#[cfg(not(unix))]` fallback.
8373        #[cfg(unix)]
8374        cmd.process_group(0);
8375        // P4c (`core.shell_env_snapshot`)/P5-10 (`env_policy`):
8376        // `build_sandboxed_sh` (above) already applied both via its own
8377        // `apply_sandbox_env_policy` last step — no separate `ctx.shell_env`
8378        // application here (that would re-add a secret `Filtered`/`None`
8379        // just stripped, on top of the already-`env_clear`'d command).
8380
8381        let mut child = match cmd.spawn() {
8382            Ok(c) => c,
8383            Err(e) => {
8384                drop(guard);
8385                let err = Error::tool(
8386                    BACKGROUND_EXEC,
8387                    format!("failed to spawn background command: {e}"),
8388                );
8389                return (format!("Error: {err}"), true);
8390            }
8391        };
8392        let pid = child.id();
8393        let output = std::sync::Arc::new(crate::background::CapturedOutput::new());
8394        let cap = self.config.tools_background_max_output_bytes;
8395        // Fire-and-forget: the reader tasks outlive this method call and
8396        // exit on their own at pipe EOF — see `spawn_output_reader`'s doc
8397        // comment. Bound to named (not `_`) locals only to keep clippy's
8398        // `let_underscore_future` lint quiet; neither handle is awaited or
8399        // aborted anywhere.
8400        if let Some(stdout) = child.stdout.take() {
8401            let _stdout_reader = spawn_output_reader(stdout, output.clone(), cap);
8402        }
8403        if let Some(stderr) = child.stderr.take() {
8404            let _stderr_reader = spawn_output_reader(stderr, output.clone(), cap);
8405        }
8406
8407        let started_at_ms = now_ms();
8408        self.background_jobs.insert(
8409            job_id.clone(),
8410            BackgroundJob {
8411                child,
8412                command: command.clone(),
8413                pid,
8414                output,
8415                started_at_ms,
8416                killed: false,
8417                _guard: guard,
8418            },
8419        );
8420
8421        let out = serde_json::json!({
8422            "job_id": job_id,
8423            "status": "running",
8424            "pid": pid,
8425            "command": command,
8426        });
8427        (out.to_string(), false)
8428    }
8429
8430    /// Execute the `background_status` intrinsic (P5-6, D1 "monitor/event
8431    /// feed"): non-blocking poll of one job's run status (via
8432    /// `Child::try_wait`), drain its output captured since the LAST poll
8433    /// and emit it as an [`AgentEvent::BackgroundOutput`] event (the
8434    /// "event feed" — a real `EventSink` consumer sees each poll's new
8435    /// output live), and return the full captured output (bounded, per
8436    /// [`Config::tools_background_max_output_bytes`]) so far either way.
8437    /// Once the job is terminal (exited or killed), this reaps it — removes
8438    /// it from [`Self::background_jobs`], freeing its concurrency slot —
8439    /// same "poll once more to reap" contract [`Self::run_subagent_status`]
8440    /// already established for background subagents.
8441    fn run_background_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8442        let args = match call.function.parsed_arguments() {
8443            Ok(v) => v,
8444            Err(e) => {
8445                let err = Error::InvalidArguments {
8446                    tool: BACKGROUND_STATUS.to_string(),
8447                    message: e.to_string(),
8448                };
8449                return (format!("Error: {err}"), true);
8450            }
8451        };
8452        let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
8453            let err = Error::InvalidArguments {
8454                tool: BACKGROUND_STATUS.to_string(),
8455                message: "`job_id` is required".to_string(),
8456            };
8457            return (format!("Error: {err}"), true);
8458        };
8459        let job_id = job_id.to_string();
8460
8461        // Scoped so the mutable borrow of `self.background_jobs` ends
8462        // before `self.emit(...)`/`self.background_jobs.remove(...)` below
8463        // need their own (mutable) access to `self`.
8464        let (command, pid, started_at_ms, status, output_so_far, truncated, delta) = {
8465            let Some(job) = self.background_jobs.get_mut(&job_id) else {
8466                let err = Error::BackgroundJobNotFound(job_id);
8467                return (format!("Error: {err}"), true);
8468            };
8469            let status = background_job_status(job);
8470            let (output_so_far, truncated) = job.output.snapshot();
8471            let delta = job.output.drain_new();
8472            (
8473                job.command.clone(),
8474                job.pid,
8475                job.started_at_ms,
8476                status,
8477                output_so_far,
8478                truncated,
8479                delta,
8480            )
8481        };
8482
8483        if !delta.is_empty() {
8484            self.emit(AgentEvent::BackgroundOutput {
8485                job_id: job_id.clone(),
8486                chunk: delta,
8487                truncated,
8488            });
8489        }
8490
8491        let exit_code = match status {
8492            crate::background::JobStatus::Exited(code) => code,
8493            _ => None,
8494        };
8495        let out = serde_json::json!({
8496            "job_id": job_id,
8497            "command": command,
8498            "status": status.as_str(),
8499            "exit_code": exit_code,
8500            "pid": pid,
8501            "started_at_ms": started_at_ms,
8502            "output": output_so_far,
8503            "output_truncated": truncated,
8504        });
8505        if !matches!(status, crate::background::JobStatus::Running) {
8506            self.background_jobs.remove(&job_id);
8507        }
8508        (out.to_string(), false)
8509    }
8510
8511    /// Execute the `background_list` intrinsic (P5-6, D10 "bg-manager"):
8512    /// list every background job this agent is currently tracking, without
8513    /// draining output or reaping anything (a read-only listing —
8514    /// `background_status` is the reaping poll).
8515    fn run_background_list(&mut self, _call: &crate::message::ToolCall) -> (String, bool) {
8516        let mut jobs = Vec::new();
8517        for (job_id, job) in self.background_jobs.iter_mut() {
8518            let status = background_job_status(job);
8519            jobs.push(serde_json::json!({
8520                "job_id": job_id,
8521                "command": job.command,
8522                "status": status.as_str(),
8523                "pid": job.pid,
8524                "started_at_ms": job.started_at_ms,
8525            }));
8526        }
8527        let out = serde_json::json!({ "jobs": jobs });
8528        (out.to_string(), false)
8529    }
8530
8531    /// Execute the `background_kill` intrinsic (P5-6, D10 "bg-manager",
8532    /// build brief "kill/cancel a job"): request REAL termination of a
8533    /// background job's OS process AND its whole process group (see
8534    /// [`kill_job_process_group`] — Fable-5 review, HIGH, "grandchildren
8535    /// orphaned on kill"; a documented no-op if the process already
8536    /// exited) and reap it immediately, freeing its concurrency slot.
8537    fn run_background_kill(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8538        let args = match call.function.parsed_arguments() {
8539            Ok(v) => v,
8540            Err(e) => {
8541                let err = Error::InvalidArguments {
8542                    tool: BACKGROUND_KILL.to_string(),
8543                    message: e.to_string(),
8544                };
8545                return (format!("Error: {err}"), true);
8546            }
8547        };
8548        let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
8549            let err = Error::InvalidArguments {
8550                tool: BACKGROUND_KILL.to_string(),
8551                message: "`job_id` is required".to_string(),
8552            };
8553            return (format!("Error: {err}"), true);
8554        };
8555        let job_id = job_id.to_string();
8556        let Some(mut job) = self.background_jobs.remove(&job_id) else {
8557            let err = Error::BackgroundJobNotFound(job_id);
8558            return (format!("Error: {err}"), true);
8559        };
8560        kill_job_process_group(&mut job);
8561        job.killed = true;
8562        let out = serde_json::json!({
8563            "job_id": job_id,
8564            "status": "killed",
8565            "pid": job.pid,
8566        });
8567        // `job` (and its `ConcurrencyGuard`) drops here, freeing the slot.
8568        (out.to_string(), false)
8569    }
8570
8571    /// P5-3 (D5 "subagent transcripts… persisted + linked"): write a
8572    /// finished child's transcript to `Self::subagent_store`, if one is
8573    /// installed — a no-op otherwise (see that field's doc comment). Builds
8574    /// the child's `Session` the same way `to_native_jsonl_v2`'s doc
8575    /// comment describes (an empty imported prefix + `transcript` as
8576    /// `appended` `NativeTurn`s), with `meta.agent_id`/`parent_tool_use_id`/
8577    /// `lineage` populated from `lineage` so the native-v2 header carries
8578    /// the full lineage record on disk (see `Session::to_native_jsonl_v2`'s
8579    /// P5-3 doc note).
8580    ///
8581    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
8582    /// cosmetic"): `Session::from_claude_code_str("")` is used ONLY to get
8583    /// a blank `raw`/`messages` skeleton cheaply (an empty string parses
8584    /// identically under any loader) — it is NOT claiming this child's
8585    /// session actually came from Claude Code. Before this fix, that
8586    /// borrowed constructor's `meta.source` (`SessionSource::ClaudeCode`)
8587    /// leaked straight through to the persisted sidecar's `source` header,
8588    /// mislabeling a native `spawn_subagent` child as an imported CC
8589    /// session. Corrected to `SessionSource::Native` immediately after —
8590    /// see that variant's doc comment.
8591    fn persist_subagent_transcript(
8592        &self,
8593        child_id: &str,
8594        lineage: &crate::subagents::SubagentLineage,
8595        transcript: &[ChatMessage],
8596    ) {
8597        let Some((store, parent_name)) = &self.subagent_store else {
8598            return;
8599        };
8600        let mut session = match Session::from_claude_code_str("") {
8601            Ok(s) => s,
8602            Err(_) => return,
8603        };
8604        session.meta.source = crate::session::SessionSource::Native;
8605        session.meta.agent_id = Some(lineage.child_agent_id.clone());
8606        session.meta.parent_tool_use_id = Some(lineage.parent_tool_use_id.clone());
8607        session.meta.lineage = lineage.to_lineage_map();
8608        let sidecar_jsonl = session.to_native_jsonl_v2(transcript);
8609        let _ = store.save_subagent_transcript(parent_name, child_id, &sidecar_jsonl);
8610        let _ = store.save_subagent_lineage(parent_name, child_id, lineage);
8611    }
8612
8613    /// Execute the `tool_search` intrinsic (B6): case-insensitive keyword
8614    /// match over `name` + `description` of every registered, enabled,
8615    /// non-core, not-yet-activated tool (builtin and `mcp__*` alike). Matches
8616    /// are activated (advertised starting with the next request) and
8617    /// returned as a JSON array of their full [`ToolSchema`]s.
8618    fn run_tool_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8619        let args = match call.function.parsed_arguments() {
8620            Ok(v) => v,
8621            Err(e) => {
8622                let err = Error::InvalidArguments {
8623                    tool: TOOL_SEARCH.to_string(),
8624                    message: e.to_string(),
8625                };
8626                return (format!("Error: {err}"), true);
8627            }
8628        };
8629        let query = args
8630            .get("query")
8631            .and_then(serde_json::Value::as_str)
8632            .unwrap_or("")
8633            .to_lowercase();
8634        let max_results = args
8635            .get("max_results")
8636            .and_then(serde_json::Value::as_u64)
8637            .map(|n| n as usize);
8638
8639        let mut matches: Vec<ToolSchema> = self
8640            .registry
8641            .iter()
8642            .filter(|t| self.config.tool_enabled(t.name()))
8643            .filter(|t| !self.is_core_tool(t.name()))
8644            .filter(|t| !self.activated_tools.contains(t.name()))
8645            .filter(|t| {
8646                query.is_empty()
8647                    || t.name().to_lowercase().contains(&query)
8648                    || self
8649                        .config
8650                        .tool_description(t.name(), t.description())
8651                        .to_lowercase()
8652                        .contains(&query)
8653            })
8654            // TR-8/T5 dev/03: the on-demand fetch always returns the ORIGINAL
8655            // full schema, never the tier-minified one — that's the invert.
8656            .map(|t| self.raw_schema_for(t))
8657            .collect();
8658
8659        if let Some(max) = max_results {
8660            matches.truncate(max);
8661        }
8662
8663        for m in &matches {
8664            self.activated_tools.insert(m.name.clone());
8665        }
8666
8667        let result = serde_json::to_string(&matches).unwrap_or_else(|_| "[]".to_string());
8668        (result, false)
8669    }
8670
8671    /// The `expand_reduction` schema (T12/TR-1), advertised whenever a
8672    /// [`ReductionPolicy`] is installed.
8673    ///
8674    /// The description deliberately never spells the literal stub sentinel
8675    /// prefix: A11's export leak guard is unconditional, so an assistant
8676    /// turn that quoted a stub line verbatim (which teaching the syntax
8677    /// invites) would permanently fail export for that session. Stubs are
8678    /// described abstractly and the model is told to pass ids only.
8679    fn expand_reduction_schema() -> ToolSchema {
8680        ToolSchema {
8681            name: EXPAND_REDUCTION.to_string(),
8682            description: "Fetch back the original content hidden behind a reduction stub in \
8683                your current view — a truncated tool output, cleared old turns, or an elided \
8684                file read that was hidden to save context. Each stub line names a reduction id \
8685                like r0042-9f3c: pass ONLY that id here, and never quote or repeat a stub line \
8686                itself in your replies. The original is durably kept in the session sidecar. \
8687                Pass `byte_range` to fetch a slice of a large one at a time instead of all of \
8688                it at once; ranged results are prefixed with a `bytes start..end of total` \
8689                header so you can plan the next slice."
8690                .to_string(),
8691            parameters: serde_json::json!({
8692                "type": "object",
8693                "properties": {
8694                    "reduction_id": {
8695                        "type": "string",
8696                        "description": "The reduction id named in the stub line, e.g. \
8697                            \"r0042-9f3c\". Pass the id alone."
8698                    },
8699                    "byte_range": {
8700                        "type": "array",
8701                        "items": {"type": "integer"},
8702                        "minItems": 2,
8703                        "maxItems": 2,
8704                        "description": "Optional [start, end) byte offsets within the original \
8705                            content to fetch instead of all of it. Exactly two non-negative \
8706                            integers with start <= end."
8707                    }
8708                },
8709                "required": ["reduction_id"],
8710                "additionalProperties": false
8711            }),
8712        }
8713    }
8714
8715    /// The `sidecar_search` schema (T12/TR-1), advertised whenever a
8716    /// [`ReductionPolicy`] is installed. Same no-literal-sentinel rule as
8717    /// [`Self::expand_reduction_schema`].
8718    fn sidecar_search_schema() -> ToolSchema {
8719        ToolSchema {
8720            name: SIDECAR_SEARCH.to_string(),
8721            description: "Search content currently hidden from your view by reduction stubs \
8722                (large tool outputs, cleared old turns, elided file reads) for a substring or \
8723                regex. Only hidden content is searched, never what you can already see. \
8724                Returns match snippets with each match's reduction_id for use with \
8725                expand_reduction; refer to results by their reduction id rather than quoting \
8726                stub lines. Results are capped — if `truncated` is true, narrow the query."
8727                .to_string(),
8728            parameters: serde_json::json!({
8729                "type": "object",
8730                "properties": {
8731                    "query": {
8732                        "type": "string",
8733                        "description": "Non-empty substring or regex to search for \
8734                            (case-insensitive)."
8735                    }
8736                },
8737                "required": ["query"],
8738                "additionalProperties": false
8739            }),
8740        }
8741    }
8742
8743    /// Reload the recorder's full recorded messages from disk (TR-1's
8744    /// `recorded` resolution source). Since TR-12's D6/A7 supersession gate
8745    /// (`Self::run_loop`), a `expand_reduction`/`sidecar_search` call only
8746    /// ever exists alongside an active [`ReductionPolicy`] (see
8747    /// [`EXPAND_REDUCTION`]'s doc), and pairing one with a recorder — as the
8748    /// CLI's reduced mode always does — means the gate is already on and
8749    /// `history[1..]` holds the same full bytes as this reload: this upgrade
8750    /// is then a dormant no-op (`reduce::rehydrate::prefer_recorded` sees
8751    /// `recorded == minted` and keeps `minted`). It stops being a no-op —
8752    /// defense in depth, not the common path — for a **legacy** sidecar
8753    /// recorded before this gate existed, or for a policy-without-recorder
8754    /// agent (gate off, so `history[1..]` still carries
8755    /// [`Self::cap_tool_output`]-capped copies): only there can `history[1..]`
8756    /// diverge from the sidecar, and only there does consulting this reload
8757    /// actually recover bytes `history[1..]` alone couldn't. `Ok(None)` when
8758    /// no recorder is attached (rehydration then resolves from history alone,
8759    /// whose capped copies — if any — carry their own honest cap notice). A
8760    /// disk-level reload is fine here regardless: these intrinsic calls are
8761    /// rare, model-initiated events, not per-request work.
8762    fn recorded_messages(&self) -> std::result::Result<Option<Vec<ChatMessage>>, String> {
8763        let Some(recorder) = &self.recorder else {
8764            return Ok(None);
8765        };
8766        let raw = std::fs::read_to_string(recorder.path())
8767            .map_err(|e| format!("failed to read the session sidecar: {e}"))?;
8768        let session = Session::from_sidecar_str(&raw)
8769            .map_err(|e| format!("failed to parse the session sidecar: {e}"))?;
8770        Ok(Some(session.messages))
8771    }
8772
8773    /// Execute the `expand_reduction` intrinsic (T12/TR-1): resolves against
8774    /// `self.reduction_log` + `self.history[1..]` (the hash-minting source),
8775    /// upgraded to the recorder's full recorded bytes for cap-diverged
8776    /// content ([`Self::recorded_messages`]; the two-source contract is
8777    /// documented on `reduce::rehydrate`). `byte_range` is validated
8778    /// strictly — any malformed shape is a model-recoverable error naming
8779    /// the expected form and the original's true size, never a silent
8780    /// whole-content (or empty) return.
8781    fn run_expand_reduction(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8782        let args = match call.function.parsed_arguments() {
8783            Ok(v) => v,
8784            Err(e) => {
8785                let err = Error::InvalidArguments {
8786                    tool: EXPAND_REDUCTION.to_string(),
8787                    message: e.to_string(),
8788                };
8789                return (format!("Error: {err}"), true);
8790            }
8791        };
8792        let Some(id) = args.get("reduction_id").and_then(serde_json::Value::as_str) else {
8793            return (
8794                "Error: expand_reduction requires a `reduction_id` string argument".to_string(),
8795                true,
8796            );
8797        };
8798        let recorded = match self.recorded_messages() {
8799            Ok(r) => r,
8800            Err(e) => return (format!("Error: expand_reduction: {e}"), true),
8801        };
8802        let recorded = recorded.as_deref();
8803
8804        // B3: strict shape validation — exactly two non-negative integers.
8805        // Anything else errors (with the true total when resolvable) rather
8806        // than silently degrading to a whole-content expand.
8807        let byte_range = match args.get("byte_range") {
8808            None | Some(serde_json::Value::Null) => None,
8809            Some(v) => {
8810                let parsed = v
8811                    .as_array()
8812                    .filter(|a| a.len() == 2)
8813                    .and_then(|a| Some((a[0].as_u64()? as usize, a[1].as_u64()? as usize)));
8814                match parsed {
8815                    Some(range) => Some(range),
8816                    None => {
8817                        let total = reduce::rehydrate::reduction_total_bytes(
8818                            &self.reduction_log,
8819                            &self.history[1..],
8820                            recorded,
8821                            id,
8822                        )
8823                        .map(|n| format!("; the original is {n} bytes"))
8824                        .unwrap_or_default();
8825                        return (
8826                            format!(
8827                                "Error: expand_reduction: malformed byte_range {v} — expected \
8828                                 [start, end): exactly two non-negative integers with \
8829                                 start <= end{total}"
8830                            ),
8831                            true,
8832                        );
8833                    }
8834                }
8835            }
8836        };
8837        match reduce::rehydrate::expand_reduction(
8838            &self.reduction_log,
8839            &self.history[1..],
8840            recorded,
8841            id,
8842            byte_range,
8843        ) {
8844            // A ranged result carries a provenance header naming the slice
8845            // and the true total, so the model can plan its next slice; a
8846            // whole-content expand stays byte-exact (TR-1 dev/01).
8847            Ok(outcome) => match outcome.range {
8848                Some((start, end)) => (
8849                    format!(
8850                        "[{id}: bytes {start}..{end} of {total}]\n{content}",
8851                        total = outcome.total_bytes,
8852                        content = outcome.content
8853                    ),
8854                    false,
8855                ),
8856                None => (outcome.content, false),
8857            },
8858            Err(e) => (format!("Error: {e}"), true),
8859        }
8860    }
8861
8862    /// Execute the `sidecar_search` intrinsic (T12/TR-1); same two-source
8863    /// resolution as [`Self::run_expand_reduction`]. The result is bounded
8864    /// by construction (`reduce::rehydrate::SidecarSearchResult`'s caps), so
8865    /// a broad query can never re-inflate the context or bloat the sidecar
8866    /// the recorder appends this result to.
8867    fn run_sidecar_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
8868        let args = match call.function.parsed_arguments() {
8869            Ok(v) => v,
8870            Err(e) => {
8871                let err = Error::InvalidArguments {
8872                    tool: SIDECAR_SEARCH.to_string(),
8873                    message: e.to_string(),
8874                };
8875                return (format!("Error: {err}"), true);
8876            }
8877        };
8878        let query = args
8879            .get("query")
8880            .and_then(serde_json::Value::as_str)
8881            .unwrap_or("");
8882        if query.trim().is_empty() {
8883            return (
8884                "Error: sidecar_search requires a non-empty `query` string argument".to_string(),
8885                true,
8886            );
8887        }
8888        let recorded = match self.recorded_messages() {
8889            Ok(r) => r,
8890            Err(e) => return (format!("Error: sidecar_search: {e}"), true),
8891        };
8892        match reduce::rehydrate::sidecar_search(
8893            &self.reduction_log,
8894            &self.history[1..],
8895            recorded.as_deref(),
8896            query,
8897        ) {
8898            Ok(result) => (
8899                serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()),
8900                false,
8901            ),
8902            Err(e) => (format!("Error: {e}"), true),
8903        }
8904    }
8905
8906    fn emit(&self, event: AgentEvent) {
8907        if let Some(sink) = &self.config.event_sink {
8908            sink(event);
8909        }
8910    }
8911
8912    /// Number of non-system messages exchanged so far.
8913    pub fn turn_count(&self) -> usize {
8914        self.history
8915            .iter()
8916            .filter(|m| m.role != Role::System)
8917            .count()
8918    }
8919
8920    /// Cumulative output (completion) tokens reported by the provider across
8921    /// every `send` on this agent. Zero if the provider reports no usage.
8922    pub fn total_output_tokens(&self) -> u64 {
8923        self.total_output_tokens
8924    }
8925}
8926
8927#[cfg(test)]
8928mod bp2_spill_tests {
8929    //! BP-2 (`.volter/tracker/markdown/BP-2.md`, catalog:58): under the
8930    //! parity presets a capped tool output stays RECOVERABLE by the model —
8931    //! without `capabilities.reduction`, which both presets leave off.
8932
8933    use super::*;
8934    use crate::configfile::{resolve, ResolveOptions};
8935
8936    /// Never called — these tests drive `cap_tool_output` directly.
8937    #[derive(Debug)]
8938    struct NeverCalledProvider;
8939
8940    #[async_trait::async_trait]
8941    impl Provider for NeverCalledProvider {
8942        async fn complete(
8943            &self,
8944            _req: &ChatRequest,
8945            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
8946        ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
8947            unreachable!("BP-2 spill tests never issue a request")
8948        }
8949    }
8950
8951    fn resolved(preset: &str) -> crate::configfile::Resolved {
8952        let toml = crate::presets::lookup(preset).unwrap();
8953        resolve(toml, None, &ResolveOptions { strict: true })
8954            .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
8955    }
8956
8957    /// The residue this closes: the cap notice said the full output was
8958    /// "not retained" / "in session sidecar" and the only door to it —
8959    /// `expand_reduction` — is advertised solely when a `ReductionPolicy`
8960    /// is installed, which `capabilities.reduction = false` never does. So
8961    /// under both parity presets a truncated output was simply lost.
8962    ///
8963    /// Now the notice NAMES a spill file, and the door is the preset's own
8964    /// read pathway: `read_file` under cc-parity, the shell under
8965    /// cx-parity (which registers no file tools at all).
8966    #[tokio::test]
8967    async fn parity_presets_spill_capped_output_and_name_a_door_the_preset_has() {
8968        for (preset, expected_door) in [
8969            ("cc-parity", "read it with `read_file`"),
8970            ("cx-parity", "read it with `cat`"),
8971        ] {
8972            let r = resolved(preset);
8973            assert!(
8974                r.config.tool_output_spill,
8975                "{preset} must set `core.tool_output_spill`"
8976            );
8977            assert_eq!(
8978                r.modules.get("reduction"),
8979                Some(&false),
8980                "{preset} leaves `capabilities.reduction` off — the spill must not depend on it"
8981            );
8982            let mut config = resolved(preset).config;
8983            config.max_tool_output_bytes = Some(1024);
8984            let registry = crate::tools::ToolRegistry::from_config(&config);
8985            let agent = Agent::with_parts(config, Box::new(NeverCalledProvider), registry);
8986            assert!(
8987                agent.reduction_policy.is_none(),
8988                "no reduction policy is installed under {preset}"
8989            );
8990
8991            let full = "R".repeat(50_000);
8992            let capped = agent.cap_tool_output(full.clone());
8993            assert!(capped.len() < full.len(), "{preset}: output must be capped");
8994            assert!(capped.contains(expected_door), "{preset}: {capped:?}");
8995
8996            // The path in the notice must actually hold the full bytes.
8997            let marker = capped.split("spilled to ").nth(1).unwrap_or_default();
8998            let path = marker.split(" — ").next().unwrap_or_default();
8999            assert!(!path.is_empty(), "{preset}: no spill path in {capped:?}");
9000            assert_eq!(
9001                std::fs::read_to_string(path).unwrap(),
9002                full,
9003                "{preset}: the spill file must hold the FULL output"
9004            );
9005
9006            // And the model can actually walk through that door: the
9007            // preset's own read pathway returns the spilled content.
9008            let ctx = build_tool_context(agent.config()).0;
9009            let recovered = match registry_read_tool(&agent) {
9010                Some(("read_file", tool)) => tool
9011                    .execute(serde_json::json!({"path": path}), &ctx)
9012                    .await
9013                    .unwrap(),
9014                Some(("bash", tool)) => tool
9015                    .execute(serde_json::json!({"command": format!("cat {path}")}), &ctx)
9016                    .await
9017                    .unwrap(),
9018                _ => panic!("{preset}: no read door registered"),
9019            };
9020            assert!(
9021                recovered.contains(&"R".repeat(2000)),
9022                "{preset}: the door must return the spilled output"
9023            );
9024            let _ = std::fs::remove_file(path);
9025        }
9026    }
9027
9028    /// The preset's read pathway: `read_file` where it exists, else the
9029    /// shell — the same choice the cap notice's wording makes.
9030    fn registry_read_tool<'a>(
9031        agent: &'a Agent,
9032    ) -> Option<(&'static str, &'a dyn crate::tools::Tool)> {
9033        if let Some(tool) = agent.registry.get("read_file") {
9034            return Some(("read_file", tool));
9035        }
9036        agent.registry.get("bash").map(|tool| ("bash", tool))
9037    }
9038
9039    /// Off (the default, every non-parity preset and every SDK embedder):
9040    /// no spill file, and the notice is byte-identical to before BP-2.
9041    #[test]
9042    fn spill_off_leaves_the_notice_unchanged_and_writes_nothing() {
9043        let config = Config::builder().max_tool_output_bytes(1024).build();
9044        assert!(!config.tool_output_spill);
9045        let agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9046        let capped = agent.cap_tool_output("S".repeat(50_000));
9047        assert!(capped.contains("full output not retained"), "{capped:?}");
9048        assert!(!capped.contains("spilled to"), "{capped:?}");
9049    }
9050}
9051
9052#[cfg(test)]
9053mod bp3_new_core_tool_tests {
9054    //! BP-3 (`.volter/tracker/markdown/BP-3.md`): the two behaviours the
9055    //! new tools can only have INSIDE the agent — plan mode narrowing the
9056    //! permissions engine, and `new_context` re-founding the request view
9057    //! through `reduce`'s handoff projection — driven over the RESOLVED
9058    //! parity presets.
9059
9060    use super::*;
9061    use crate::configfile::{resolve, ResolveOptions};
9062
9063    #[derive(Debug)]
9064    struct NeverCalledProvider;
9065
9066    #[async_trait::async_trait]
9067    impl Provider for NeverCalledProvider {
9068        async fn complete(
9069            &self,
9070            _req: &ChatRequest,
9071            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9072        ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9073            unreachable!("BP-3 tests never issue a request")
9074        }
9075    }
9076
9077    fn resolved(preset: &str) -> Config {
9078        let toml = crate::presets::lookup(preset).unwrap();
9079        resolve(toml, None, &ResolveOptions { strict: true })
9080            .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
9081            .config
9082    }
9083
9084    fn agent_for(preset: &str) -> Agent {
9085        Agent::with_provider(resolved(preset), Box::new(NeverCalledProvider))
9086    }
9087
9088    /// An approval door that says yes to everything — so a denial in these
9089    /// tests can only come from the DENY tier, never from cc-parity's
9090    /// `approval = "untrusted"` ask default.
9091    struct AlwaysAllow;
9092    impl crate::permissions::PermissionsApprovalHandler for AlwaysAllow {
9093        fn ask(
9094            &self,
9095            _req: &crate::permissions::ApprovalRequest,
9096        ) -> crate::permissions::ApprovalOutcome {
9097            crate::permissions::ApprovalOutcome::Allow
9098        }
9099    }
9100
9101    fn call(name: &str, args: serde_json::Value) -> crate::message::ToolCall {
9102        crate::message::ToolCall {
9103            id: format!("call-{name}"),
9104            kind: "function".to_string(),
9105            function: crate::message::FunctionCall {
9106                name: name.to_string(),
9107                arguments: args.to_string(),
9108            },
9109        }
9110    }
9111
9112    /// The row `plan-mode-read-only-research-phase` claims: under the
9113    /// resolved `cc-parity` preset, entering plan mode makes the
9114    /// permissions engine REFUSE write and execution tools — and the
9115    /// refusal survives an approval door that allows everything, because
9116    /// the mode contributes DENY rules, the tier no approval can override.
9117    #[test]
9118    fn cc_parity_plan_mode_denies_writes_through_the_permissions_engine() {
9119        let mut agent = agent_for("cc-parity");
9120        assert!(
9121            agent.config.permissions_enabled,
9122            "cc-parity runs the permissions engine; plan mode narrows it"
9123        );
9124        agent.set_permissions_approval_handler(AlwaysAllow);
9125
9126        let write = serde_json::json!({"path": "notes.txt", "content": "x"});
9127        let bash = serde_json::json!({"command": "echo hi"});
9128        assert!(
9129            agent
9130                .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9131                .is_none(),
9132            "outside plan mode an allowed write must pass"
9133        );
9134        assert!(agent
9135            .permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
9136            .is_none());
9137
9138        agent.plan_mode().enter(Some("research first"));
9139
9140        let denial = agent
9141            .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9142            .expect("plan mode must refuse a write");
9143        assert!(denial.contains("Deny"), "{denial}");
9144        assert!(agent
9145            .permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
9146            .is_some());
9147        assert!(agent
9148            .permissions_gate_denial(
9149                "apply_patch",
9150                &serde_json::json!({"patch": "*** Begin Patch\n*** End Patch"}),
9151                crate::config::HookDecision::Pass
9152            )
9153            .is_some());
9154
9155        // The research surface, and the way out, stay open.
9156        for (tool, args) in [
9157            ("read_file", serde_json::json!({"path": "notes.txt"})),
9158            ("glob", serde_json::json!({"pattern": "*.rs"})),
9159            ("exit_plan_mode", serde_json::json!({"plan": "the plan"})),
9160            ("ask_user", serde_json::json!({"questions": []})),
9161        ] {
9162            assert!(
9163                agent
9164                    .permissions_gate_denial(tool, &args, crate::config::HookDecision::Pass)
9165                    .is_none(),
9166                "plan mode must leave `{tool}` reachable"
9167            );
9168        }
9169
9170        agent.plan_mode().exit();
9171        assert!(
9172            agent
9173                .permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
9174                .is_none(),
9175            "leaving plan mode restores the write surface"
9176        );
9177    }
9178
9179    /// The `context-budget-tools` row's read half: the figure
9180    /// `get_context_remaining` reports is the agent's OWN accounting,
9181    /// computed at the moment the tool asks for it.
9182    #[test]
9183    fn cx_parity_publishes_its_context_accounting_when_the_budget_tool_runs() {
9184        let mut agent = agent_for("cx-parity");
9185        assert!(agent.ctx.context_budget.snapshot().is_none(), "nothing yet");
9186        agent
9187            .history
9188            .push(ChatMessage::user("x".repeat(4000).to_string()));
9189        let _ = agent.prepare_tool_call(&call("current_time", serde_json::json!({})));
9190        assert!(
9191            agent.ctx.context_budget.snapshot().is_none(),
9192            "an unrelated tool call must not pay for the accounting"
9193        );
9194
9195        let _ = agent.prepare_tool_call(&call("get_context_remaining", serde_json::json!({})));
9196        let published = agent
9197            .ctx
9198            .context_budget
9199            .snapshot()
9200            .expect("the budget tool's own call publishes it");
9201        // What the model reads IS `Agent::context_usage()` — the same
9202        // struct `/context` prints and the guard enforces, not a second
9203        // estimate that could disagree with it.
9204        assert_eq!(
9205            published,
9206            serde_json::to_value(agent.context_usage()).unwrap()
9207        );
9208        assert!(published["context_limit"].as_u64().unwrap() > 0);
9209        assert!(published["remaining_tokens"].as_u64().unwrap() > 0);
9210    }
9211
9212    /// The `context-budget-tools` row's write half, over the resolved
9213    /// `cx-parity` preset: a parked `new_context` request re-founds the
9214    /// request view through `reduce`'s handoff projection — objective in
9215    /// the leading system message, the tail kept, the rest covered by
9216    /// `TurnsCleared` spans that land in this agent's own reduction log.
9217    #[test]
9218    fn cx_parity_new_context_rebuilds_the_window_through_the_same_handoff_the_operator_gets() {
9219        let mut agent = agent_for("cx-parity");
9220        agent.history.push(ChatMessage::system("system"));
9221        for i in 0..12 {
9222            agent.history.push(ChatMessage::user(format!("turn {i}")));
9223        }
9224        let before = agent.history.clone();
9225
9226        agent
9227            .ctx
9228            .context_budget
9229            .request_new_context(crate::tools::NewContextRequest {
9230                objective: "finish the parser".to_string(),
9231                keep_recent: Some(2),
9232            });
9233        agent.apply_pending_new_context();
9234
9235        // Exactly what `/handoff` produces — the model's door and the
9236        // operator's door run one mechanism, so this compares against it.
9237        let mut expected =
9238            Agent::with_provider(resolved("cx-parity"), Box::new(NeverCalledProvider));
9239        expected.history = before.clone();
9240        expected.new_context("finish the parser", Some(2));
9241        assert_eq!(agent.history, expected.history);
9242
9243        assert!(
9244            agent.history.len() < before.len(),
9245            "the window must actually shrink: {} -> {}",
9246            before.len(),
9247            agent.history.len()
9248        );
9249        assert_eq!(agent.history[0], before[0], "the system prompt survives");
9250        let marker = agent.history[1].content.clone().unwrap_or_default();
9251        assert!(marker.contains("fresh working context"), "{marker}");
9252        assert!(marker.contains("finish the parser"), "{marker}");
9253        assert_eq!(
9254            agent.history[agent.history.len() - 2..],
9255            before[before.len() - 2..],
9256            "the requested tail is kept verbatim"
9257        );
9258        assert!(
9259            agent.ctx.context_budget.take_new_context().is_none(),
9260            "the request is consumed exactly once"
9261        );
9262    }
9263
9264    /// `new_context` never trades recoverability for a smaller window: with
9265    /// no sidecar recorder the request is refused, the reason is handed
9266    /// back to the model, and the transcript keeps every turn.
9267    #[test]
9268    fn cx_parity_new_context_states_the_retention_it_actually_has() {
9269        let mut agent = agent_for("cx-parity");
9270        agent.history.push(ChatMessage::system("system"));
9271        for i in 0..12 {
9272            agent.history.push(ChatMessage::user(format!("turn {i}")));
9273        }
9274        agent
9275            .ctx
9276            .context_budget
9277            .request_new_context(crate::tools::NewContextRequest {
9278                objective: "finish the parser".to_string(),
9279                keep_recent: Some(2),
9280            });
9281        agent.apply_pending_new_context();
9282
9283        // No recorder is installed here, and the marker says so rather than
9284        // implying the set-aside turns are still somewhere.
9285        let marker = agent.history[1].content.clone().unwrap_or_default();
9286        assert!(
9287            marker.contains("No transcript sidecar is attached"),
9288            "the marker must not overstate retention: {marker}"
9289        );
9290    }
9291}
9292
9293/// BP-8 (catalog:152): what [`Agent::rewind_conversation`] did.
9294#[derive(Debug, Clone, PartialEq, Eq)]
9295pub struct RewindOutcome {
9296    /// Messages remaining, including the system message at index 0.
9297    pub kept: usize,
9298    /// Messages removed from the live conversation (still on disk, in the
9299    /// journal, and still in the tree under `preserved_branch`).
9300    pub removed: usize,
9301    /// When the tree module is on and the rewind actually moved the leaf:
9302    /// the sibling branch the old leaf was preserved under, so the rewound
9303    /// path stays independently addressable.
9304    pub preserved_branch: Option<String>,
9305}
9306
9307#[cfg(test)]
9308mod bp1_compaction_tests {
9309    //! BP-1 (`.volter/tracker/markdown/BP-1.md` AC2): `cx-parity` fires
9310    //! [`Agent::maybe_compact`] at its own trigger, and
9311    //! `core.compaction.summarize` reaches [`Config`] and is what the
9312    //! compaction marker says.
9313
9314    use super::*;
9315    use crate::configfile::{resolve, ResolveOptions};
9316
9317    /// Never called — these tests drive `maybe_compact` directly, which
9318    /// makes no request.
9319    #[derive(Debug)]
9320    struct NeverCalledProvider;
9321
9322    #[async_trait::async_trait]
9323    impl Provider for NeverCalledProvider {
9324        async fn complete(
9325            &self,
9326            _req: &ChatRequest,
9327            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9328        ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9329            unreachable!("BP-1 compaction tests never issue a request")
9330        }
9331    }
9332
9333    fn resolved_cx_parity() -> Config {
9334        let toml = crate::presets::lookup("cx-parity").unwrap();
9335        resolve(toml, None, &ResolveOptions { strict: true })
9336            .expect("cx-parity resolves")
9337            .config
9338    }
9339
9340    /// Enough history to sit inside `reserve_tokens` of ANY model context
9341    /// window (`estimate_view_tokens` is size-proportional, and the largest
9342    /// window in the catalog is far below this).
9343    fn stuff_history(agent: &mut Agent) {
9344        agent.history.push(ChatMessage::system("system"));
9345        for i in 0..400 {
9346            agent
9347                .history
9348                .push(ChatMessage::user(format!("turn {i}: {}", "x".repeat(8000))));
9349        }
9350    }
9351
9352    /// The defect: `cx-parity` armed NEITHER compaction trigger, so
9353    /// `maybe_compact` returned `false` on its
9354    /// `threshold.is_none() && compaction_reserve_tokens.is_none()` guard
9355    /// no matter how large the conversation grew.
9356    #[test]
9357    fn cx_parity_fires_maybe_compact_at_its_pressure_trigger() {
9358        let config = resolved_cx_parity();
9359        assert!(config.compaction_enabled);
9360        assert_eq!(config.compaction_reserve_tokens, Some(16384));
9361        assert!(config.compaction_summarize);
9362
9363        let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9364        stuff_history(&mut agent);
9365        let before = agent.history.len();
9366        assert!(
9367            agent.maybe_compact(),
9368            "cx-parity must compact under context pressure"
9369        );
9370        assert!(agent.history.len() < before, "history must actually shrink");
9371        let marker = agent
9372            .history
9373            .iter()
9374            .find(|m| {
9375                m.content
9376                    .as_deref()
9377                    .is_some_and(|c| c.contains("earlier conversation compacted"))
9378            })
9379            .expect("a compaction marker must be present");
9380        assert!(
9381            marker
9382                .content
9383                .as_deref()
9384                .unwrap()
9385                .contains("summarized to save context"),
9386            "cx-parity sets `core.compaction.summarize = true`"
9387        );
9388    }
9389
9390    /// `core.compaction.summarize = false` reaches `Config` too, and the
9391    /// marker then states only what actually happened to the span.
9392    #[test]
9393    fn compaction_summarize_false_reaches_config_and_changes_the_marker() {
9394        let toml = "extends = \"cx-parity\"\n[core.compaction]\nsummarize = false\n";
9395        let config = resolve(toml, None, &ResolveOptions::default())
9396            .expect("resolves")
9397            .config;
9398        assert!(!config.compaction_summarize);
9399
9400        let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
9401        stuff_history(&mut agent);
9402        assert!(agent.maybe_compact());
9403        let marker = agent
9404            .history
9405            .iter()
9406            .find(|m| {
9407                m.content
9408                    .as_deref()
9409                    .is_some_and(|c| c.contains("earlier conversation compacted"))
9410            })
9411            .expect("a compaction marker must be present");
9412        let text = marker.content.as_deref().unwrap();
9413        assert!(text.contains("cleared to save context"), "got: {text}");
9414        assert!(!text.contains("summarized"));
9415    }
9416}
9417
9418#[cfg(test)]
9419mod api_key_cmd_tests {
9420    //! P4 (design §5.2, §1.8 D6 row): `api_key_cmd` credential-helper
9421    //! resolution. `Agent::new` never makes a network call, so these tests
9422    //! exercise the real resolution chain end-to-end without mocking.
9423
9424    use super::*;
9425
9426    /// Default-off: with no `api_key`/`api_key_cmd` set and an env var that
9427    /// isn't set either, resolution fails exactly as it always has —
9428    /// `api_key_cmd` being a brand-new field changes nothing when unset.
9429    #[test]
9430    fn default_none_falls_through_to_missing_api_key_error() {
9431        let config = Config::builder()
9432            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD")
9433            .build();
9434        assert!(config.api_key.is_none());
9435        assert!(config.api_key_cmd.is_none());
9436        let err = Agent::new(config).err().expect("no key source configured");
9437        assert!(matches!(err, Error::MissingApiKey(_)));
9438    }
9439
9440    /// Happy path: `api_key_cmd` alone (no `api_key`, no matching env var)
9441    /// is enough for `Agent::new` to succeed — the helper's stdout is
9442    /// resolved and used.
9443    #[test]
9444    fn api_key_cmd_alone_resolves_successfully() {
9445        let config = Config::builder()
9446            .api_key_cmd("echo sk-test-from-helper")
9447            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_2")
9448            .build();
9449        assert!(Agent::new(config).is_ok());
9450    }
9451
9452    /// A failing helper command (non-zero exit, or empty stdout) falls
9453    /// through to `api_key_env` rather than propagating the helper's own
9454    /// failure — same "try the next source" posture as every other layer.
9455    #[test]
9456    fn api_key_cmd_failure_falls_through_to_env() {
9457        std::env::set_var(
9458            "SUPERCODE_TEST_API_KEY_CMD_FALLBACK",
9459            "sk-from-env-fallback",
9460        );
9461        let config = Config::builder()
9462            .api_key_cmd("exit 1")
9463            .api_key_env("SUPERCODE_TEST_API_KEY_CMD_FALLBACK")
9464            .build();
9465        assert!(Agent::new(config).is_ok());
9466        std::env::remove_var("SUPERCODE_TEST_API_KEY_CMD_FALLBACK");
9467    }
9468
9469    /// A failing helper AND no fallback env var still produces the same
9470    /// `MissingApiKey` error today's no-key path always produced — the new
9471    /// source never turns a hard failure into a silent empty key.
9472    #[test]
9473    fn api_key_cmd_failure_with_no_fallback_still_errors() {
9474        let config = Config::builder()
9475            .api_key_cmd("exit 1")
9476            .api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_3")
9477            .build();
9478        let err = Agent::new(config)
9479            .err()
9480            .expect("helper failed, no env fallback");
9481        assert!(matches!(err, Error::MissingApiKey(_)));
9482    }
9483
9484    /// `run_api_key_cmd` directly: happy path trims trailing whitespace/
9485    /// newline from the command's stdout.
9486    #[test]
9487    fn run_api_key_cmd_trims_output() {
9488        assert_eq!(run_api_key_cmd("echo '  sk-abc123  '"), "sk-abc123");
9489    }
9490
9491    /// `run_api_key_cmd` directly: a nonexistent binary fails to spawn and
9492    /// returns an empty string rather than panicking.
9493    #[test]
9494    fn run_api_key_cmd_spawn_failure_returns_empty() {
9495        // `sh -c` itself always spawns; feed it a command that can't run.
9496        assert_eq!(
9497            run_api_key_cmd("/no/such/binary/at/all --flag"),
9498            String::new()
9499        );
9500    }
9501}
9502
9503#[cfg(test)]
9504mod bp4_prompt_context_tests {
9505    //! BP-4 (`.volter/tracker/markdown/BP-4.md`): the prompt/context knobs
9506    //! the parity presets never set, proved over the RESOLVED `cc-parity` /
9507    //! `cx-parity` configs (not over hand-built `Config`s — a preset that
9508    //! doesn't arm the knob would pass that weaker test).
9509
9510    use super::*;
9511    use crate::configfile::{resolve, ResolveOptions};
9512
9513    fn resolved(preset: &str) -> Config {
9514        let toml = crate::presets::lookup(preset).unwrap();
9515        resolve(toml, None, &ResolveOptions { strict: true })
9516            .unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
9517            .config
9518    }
9519
9520    /// Never called — these tests assemble prompts and drive
9521    /// `refresh_env_context`/`inject_context_block`, none of which issue a
9522    /// request.
9523    #[derive(Debug)]
9524    struct NoProvider;
9525
9526    #[async_trait::async_trait]
9527    impl Provider for NoProvider {
9528        async fn complete(
9529            &self,
9530            _req: &ChatRequest,
9531            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
9532        ) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
9533            unreachable!("BP-4 prompt/context tests never issue a request")
9534        }
9535    }
9536
9537    fn scratch(tag: &str) -> std::path::PathBuf {
9538        let dir = std::env::temp_dir().join(format!(
9539            "supercode-bp4-{tag}-{}-{}",
9540            std::process::id(),
9541            std::time::SystemTime::now()
9542                .duration_since(std::time::UNIX_EPOCH)
9543                .unwrap()
9544                .as_nanos()
9545        ));
9546        std::fs::create_dir_all(&dir).unwrap();
9547        dir
9548    }
9549
9550    /// Row `project-instruction-files-w-directory-walk`: a CLAUDE.md above
9551    /// the working directory is discovered, ordered root→cwd (nearest wins
9552    /// by appearing last), and the climb STOPS at the `.git` root — the
9553    /// directory above it is never read.
9554    #[test]
9555    fn presets_walk_ancestors_up_to_the_git_root_nearest_last() {
9556        for preset in ["cc-parity", "cx-parity"] {
9557            let base = scratch("walk");
9558            let above = base.join("above");
9559            let root = above.join("repo");
9560            let deep = root.join("crates").join("thing");
9561            std::fs::create_dir_all(&deep).unwrap();
9562            std::fs::create_dir_all(root.join(".git")).unwrap();
9563            std::fs::write(above.join("CLAUDE.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
9564            std::fs::write(above.join("AGENTS.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
9565            std::fs::write(root.join("CLAUDE.md"), "REPO-ROOT-MARKER").unwrap();
9566            std::fs::write(root.join("AGENTS.md"), "REPO-ROOT-MARKER").unwrap();
9567            std::fs::write(deep.join("CLAUDE.md"), "NEAREST-DIR-MARKER").unwrap();
9568            std::fs::write(deep.join("AGENTS.md"), "NEAREST-DIR-MARKER").unwrap();
9569
9570            let mut config = resolved(preset);
9571            config.cwd = deep.clone();
9572            let blob = assemble_project_instructions(&config);
9573
9574            let root_at = blob
9575                .find("REPO-ROOT-MARKER")
9576                .unwrap_or_else(|| panic!("{preset}: the ancestor repo root was not walked"));
9577            let near_at = blob
9578                .find("NEAREST-DIR-MARKER")
9579                .unwrap_or_else(|| panic!("{preset}: cwd's own file was not loaded"));
9580            assert!(
9581                root_at < near_at,
9582                "{preset}: nearest-to-cwd must win by appearing LAST (root→cwd)"
9583            );
9584            assert!(
9585                !blob.contains("ABOVE-THE-ROOT-MARKER"),
9586                "{preset}: the walk must stop at the `.git` root"
9587            );
9588            let _ = std::fs::remove_dir_all(&base);
9589        }
9590    }
9591
9592    /// The walk is bounded even with no root marker anywhere: it terminates
9593    /// at the filesystem root instead of looping.
9594    #[test]
9595    fn walk_terminates_without_a_root_marker() {
9596        let base = scratch("nomarker");
9597        let deep = base.join("a").join("b").join("c");
9598        std::fs::create_dir_all(&deep).unwrap();
9599        let mut config = resolved("cx-parity");
9600        config.cwd = deep.clone();
9601        let roots = instruction_walk_roots(&config);
9602        assert!(roots.len() <= MAX_INSTRUCTION_WALK_DEPTH);
9603        assert_eq!(roots.last().unwrap(), &deep, "cwd is the LAST root");
9604        let _ = std::fs::remove_dir_all(&base);
9605    }
9606
9607    /// Row `instruction-file-hygiene-controls`, cx half: `cx-parity` sets
9608    /// the documented 32 KiB `project_doc_max_bytes`, and it binds per file
9609    /// AND over the aggregate, each with its own notice.
9610    #[test]
9611    fn cx_parity_enforces_the_documented_instruction_byte_cap() {
9612        let config = resolved("cx-parity");
9613        assert_eq!(
9614            config.project_doc_max_bytes,
9615            Some(32_768),
9616            "cx-parity must arm cx§2's documented 32 KiB cap"
9617        );
9618
9619        let base = scratch("cap");
9620        std::fs::create_dir_all(base.join(".git")).unwrap();
9621        std::fs::write(base.join("CLAUDE.md"), "x".repeat(40_000)).unwrap();
9622        std::fs::write(base.join("AGENTS.md"), "y".repeat(40_000)).unwrap();
9623        let mut config = config;
9624        config.cwd = base.clone();
9625        let blob = assemble_project_instructions(&config);
9626        assert!(
9627            blob.contains("[supercode: file truncated at core.project_doc_max_bytes]"),
9628            "the per-file cap must fire with a notice"
9629        );
9630        assert!(
9631            blob.contains(
9632                "[supercode: instruction content truncated at core.project_doc_max_bytes]"
9633            ),
9634            "the aggregate cap must fire with a notice"
9635        );
9636        assert!(blob.len() < 33_200, "aggregate blob stayed over the cap");
9637        let _ = std::fs::remove_dir_all(&base);
9638    }
9639
9640    /// Row `instruction-file-hygiene-controls`, cc half: `cc-parity` arms
9641    /// CC's own two levers — HTML-comment stripping and `claudeMdExcludes`
9642    /// — and arms NO byte cap, because CC documents none.
9643    #[test]
9644    fn cc_parity_strips_html_comments_and_honours_excludes() {
9645        let mut config = resolved("cc-parity");
9646        assert!(config.project_doc_strip_comments, "cc strips `<!-- … -->`");
9647        assert_eq!(
9648            config.project_doc_max_bytes, None,
9649            "`project_doc_max_bytes = 0` is §3.1's spelling for uncapped"
9650        );
9651
9652        let base = scratch("hygiene");
9653        std::fs::create_dir_all(base.join(".git")).unwrap();
9654        std::fs::write(
9655            base.join("CLAUDE.md"),
9656            "KEEP-THIS<!-- MAINTAINER-NOTE -->AND-THIS",
9657        )
9658        .unwrap();
9659        std::fs::write(base.join("AGENTS.md"), "EXCLUDED-FILE-MARKER").unwrap();
9660        config.cwd = base.clone();
9661        config.project_doc_excludes = vec!["AGENTS.md".to_string()];
9662        let blob = assemble_project_instructions(&config);
9663        assert!(blob.contains("KEEP-THIS") && blob.contains("AND-THIS"));
9664        assert!(
9665            !blob.contains("MAINTAINER-NOTE"),
9666            "block HTML comments must be stripped before injection"
9667        );
9668        assert!(
9669            !blob.contains("EXCLUDED-FILE-MARKER"),
9670            "an excluded instruction file must never be read into the prompt"
9671        );
9672        let _ = std::fs::remove_dir_all(&base);
9673    }
9674
9675    /// A project layer must not be able to suppress the user's own global
9676    /// instruction files by adding an exclude pattern (§3.3 trust boundary).
9677    #[test]
9678    fn project_layer_cannot_set_instruction_excludes() {
9679        let hc = crate::configfile::HarnessConfig::from_toml_str(
9680            "schema_version = 1\n[core]\nproject_doc_excludes = [\"CLAUDE.md\"]\n",
9681        )
9682        .unwrap();
9683        let (sanitized, dropped) = crate::configfile::sanitize_for_project(&hc);
9684        assert!(sanitized.core.project_doc_excludes.is_none());
9685        assert!(dropped.iter().any(|d| d == "core.project_doc_excludes"));
9686    }
9687
9688    /// Row `environment-context-block`: both presets emit the policy line
9689    /// the row's semantics name, and the block is RE-EMITTED when the thing
9690    /// it describes moves (cx§2 "re-emitted on change").
9691    #[test]
9692    fn env_context_block_carries_policy_and_re_emits_on_change() {
9693        for preset in ["cc-parity", "cx-parity"] {
9694            let base = scratch("env");
9695            // A SIBLING, not a child: `contains` assertions below must not
9696            // be satisfiable by a path prefix.
9697            let here = base.join("here");
9698            let other = base.join("elsewhere");
9699            std::fs::create_dir_all(&here).unwrap();
9700            std::fs::create_dir_all(&other).unwrap();
9701
9702            let mut config = resolved(preset);
9703            config.cwd = here.clone();
9704            assert!(config.env_context, "{preset} must set core.env_context");
9705            let expected_policy = format!(
9706                "approval policy: {} · sandbox: {}",
9707                approval_policy_label(config.approval),
9708                sandbox_policy_label(config.sandbox),
9709            );
9710
9711            let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9712            let system = agent.history[0].content.clone().unwrap_or_default();
9713            assert!(
9714                system.contains(&expected_policy),
9715                "{preset}: the environment block must state the approval/sandbox policy — {system}"
9716            );
9717            assert!(system.contains(&format!("cwd: {}", here.display())));
9718
9719            // Nothing moved ⇒ no churn (the prompt cache is not busted for
9720            // free).
9721            assert!(!agent.refresh_env_context(), "{preset}: spurious re-emit");
9722
9723            // cwd + policy move mid-session.
9724            agent.config.cwd = other.clone();
9725            agent.config.approval = crate::config::ApprovalPolicy::Untrusted;
9726            assert!(
9727                agent.refresh_env_context(),
9728                "{preset}: change not re-emitted"
9729            );
9730            let system = agent.history[0].content.clone().unwrap_or_default();
9731            assert!(
9732                system.contains(&format!("cwd: {}", other.display())),
9733                "{preset}: the fresh cwd must reach the model"
9734            );
9735            assert!(system.contains("approval policy: untrusted"));
9736            assert!(
9737                !system.contains(&format!("cwd: {}", here.display())),
9738                "{preset}: the stale block must be REPLACED, not duplicated"
9739            );
9740            assert_eq!(
9741                system.matches("# Environment").count(),
9742                1,
9743                "{preset}: exactly one environment block"
9744            );
9745            let _ = std::fs::remove_dir_all(&base);
9746        }
9747    }
9748
9749    /// Row `synthetic-context-injection-blocks`: both presets arm the
9750    /// registry, the built-in blocks reach the assembled system prompt, and
9751    /// a block spliced mid-session reaches it too.
9752    #[test]
9753    fn presets_splice_builtin_and_runtime_context_blocks() {
9754        for preset in ["cc-parity", "cx-parity"] {
9755            let config = resolved(preset);
9756            assert!(
9757                config.context_injections,
9758                "{preset} must set core.context_injections"
9759            );
9760            let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9761            let system = agent.history[0].content.clone().unwrap_or_default();
9762            assert!(
9763                system.contains("# Task list"),
9764                "{preset}: a built-in ambient block must reach the prompt"
9765            );
9766
9767            assert!(agent.inject_context_block("Mid session", "SPLICED-BODY-MARKER"));
9768            let system = agent.history[0].content.clone().unwrap_or_default();
9769            assert!(
9770                system.contains("# Mid session") && system.contains("SPLICED-BODY-MARKER"),
9771                "{preset}: a runtime splice must reach the prompt"
9772            );
9773            assert_eq!(agent.spliced_context_blocks().len(), 1);
9774        }
9775    }
9776
9777    /// A deterministic stand-in for the CLI's real provider-backed
9778    /// summarizer: it records exactly what it was asked to summarize, so the
9779    /// test can prove the `/compact <focus>` text reached the summarizer's
9780    /// INPUT and not only the marker.
9781    #[derive(Debug, Default)]
9782    struct RecordingSummarizer {
9783        seen: std::sync::Mutex<Vec<String>>,
9784    }
9785
9786    impl reduce::summarize::SpanSummarizer for RecordingSummarizer {
9787        fn summarize(&self, span_text: &str) -> reduce::Result<String> {
9788            self.seen
9789                .lock()
9790                .unwrap_or_else(std::sync::PoisonError::into_inner)
9791                .push(span_text.to_string());
9792            Ok("MODEL-WRITTEN-SUMMARY".to_string())
9793        }
9794
9795        fn model_id(&self) -> &str {
9796            "test-summarizer"
9797        }
9798    }
9799
9800    fn stuffed_agent(preset: &str) -> Agent {
9801        let config = resolved(preset);
9802        let mut agent = Agent::with_provider(config, Box::new(NoProvider));
9803        for i in 0..40 {
9804            agent.history.push(ChatMessage::user(format!("turn {i}")));
9805            agent
9806                .history
9807                .push(ChatMessage::assistant(format!("reply {i}")));
9808        }
9809        agent
9810    }
9811
9812    /// Row `manual-compact-with-focus-instructions`: `/compact <focus>`
9813    /// compacts on demand (no trigger needed) and the focus text lands in
9814    /// BOTH the summarizer's input and the marker.
9815    #[test]
9816    fn presets_manual_compact_carries_focus_into_the_summarizer_and_the_marker() {
9817        for preset in ["cc-parity", "cx-parity"] {
9818            let config = resolved(preset);
9819            assert!(
9820                config.compaction_focus_instructions.is_some(),
9821                "{preset} must state core.compaction.focus_instructions"
9822            );
9823            let mut agent = stuffed_agent(preset);
9824            let summarizer = std::sync::Arc::new(RecordingSummarizer::default());
9825            agent.set_span_summarizer_arc(summarizer.clone());
9826
9827            let before = agent.history().len();
9828            assert!(
9829                agent.compact_now(Some("keep the migration steps")),
9830                "{preset}: /compact must compact on demand"
9831            );
9832            assert!(agent.history().len() < before, "{preset}: nothing dropped");
9833
9834            let marker = agent
9835                .history()
9836                .iter()
9837                .find_map(|m| m.content.as_deref())
9838                .filter(|c| c.contains("earlier conversation compacted"))
9839                .or_else(|| {
9840                    agent
9841                        .history()
9842                        .iter()
9843                        .filter_map(|m| m.content.as_deref())
9844                        .find(|c| c.contains("earlier conversation compacted"))
9845                })
9846                .unwrap_or_else(|| panic!("{preset}: no compaction marker"))
9847                .to_string();
9848            assert!(
9849                marker.contains("Focus: keep the migration steps"),
9850                "{preset}: {marker}"
9851            );
9852
9853            let seen = summarizer
9854                .seen
9855                .lock()
9856                .unwrap_or_else(std::sync::PoisonError::into_inner);
9857            assert_eq!(seen.len(), 1, "{preset}: exactly one side-call");
9858            assert!(
9859                seen[0].contains("keep the migration steps"),
9860                "{preset}: the focus must reach the summarizer INPUT — {}",
9861                &seen[0][..seen[0].len().min(200)]
9862            );
9863        }
9864    }
9865
9866    /// Row `llm-summaries-of-cleared-spans`: the model-written summary is
9867    /// produced under the presets WITHOUT `capabilities.reduction` — design
9868    /// §1.5 puts "an LLM summary of the compacted span" in core obligation
9869    /// 5, knob `[core.compaction] summarize`.
9870    #[test]
9871    fn presets_summarize_the_cleared_span_without_the_reduction_module() {
9872        for preset in ["cc-parity", "cx-parity"] {
9873            let toml = crate::presets::lookup(preset).unwrap();
9874            let r = resolve(toml, None, &ResolveOptions { strict: true }).unwrap();
9875            assert_eq!(
9876                r.modules.get("reduction"),
9877                Some(&false),
9878                "{preset}: this row must hold with the reduction module OFF"
9879            );
9880            assert!(r.config.compaction_summarize);
9881
9882            let mut agent = stuffed_agent(preset);
9883            agent.set_span_summarizer_arc(std::sync::Arc::new(RecordingSummarizer::default()));
9884            assert!(agent.compact_now(None));
9885            let marker = agent
9886                .history()
9887                .iter()
9888                .filter_map(|m| m.content.as_deref())
9889                .find(|c| c.contains("earlier conversation compacted"))
9890                .unwrap_or_else(|| panic!("{preset}: no compaction marker"));
9891            assert!(
9892                marker.contains("MODEL-WRITTEN-SUMMARY"),
9893                "{preset}: the marker must carry the model-written summary — {marker}"
9894            );
9895        }
9896    }
9897
9898    /// BP-11 (catalog "Lifecycle hooks, config-registered"): the compaction
9899    /// boundary is observable — `pre_compact` fires once the compaction is
9900    /// decided (with the manual/auto trigger named) and `post_compact` once
9901    /// the window has been rewritten, under both parity presets.
9902    #[test]
9903    fn compaction_fires_pre_and_post_lifecycle_events_under_both_presets() {
9904        use crate::config::LifecycleEvent;
9905        for preset in ["cc-parity", "cx-parity"] {
9906            let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9907            let mut agent = stuffed_agent(preset);
9908            let sink = seen.clone();
9909            agent.set_lifecycle_hook(Box::new(move |event| {
9910                sink.lock().unwrap().push(event.clone());
9911            }));
9912            let before = agent.history().len();
9913            assert!(agent.compact_now(Some("keep the plan")), "{preset}");
9914            let after = agent.history().len();
9915            let seen = seen.lock().unwrap();
9916            assert_eq!(seen.len(), 2, "{preset}: exactly pre + post — {seen:?}");
9917            match &seen[0] {
9918                LifecycleEvent::PreCompact {
9919                    messages,
9920                    dropped,
9921                    manual,
9922                } => {
9923                    assert_eq!(*messages, before, "{preset}");
9924                    assert!(*dropped > 0, "{preset}");
9925                    assert!(*manual, "{preset}: /compact is the manual trigger");
9926                }
9927                other => panic!("{preset}: first event must be PreCompact, got {other:?}"),
9928            }
9929            match &seen[1] {
9930                LifecycleEvent::PostCompact { messages, dropped } => {
9931                    assert_eq!(*messages, after, "{preset}");
9932                    assert_eq!(
9933                        *dropped,
9934                        before - after + 1,
9935                        "{preset}: dropped span + 1 marker"
9936                    );
9937                }
9938                other => panic!("{preset}: second event must be PostCompact, got {other:?}"),
9939            }
9940        }
9941    }
9942
9943    /// The automatic trigger reports itself as such, and a window too small
9944    /// to compact fires nothing at all (no pre without a post).
9945    #[test]
9946    fn automatic_compaction_reports_the_auto_trigger_and_a_no_op_fires_nothing() {
9947        use crate::config::LifecycleEvent;
9948        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9949        let mut agent = stuffed_agent("cc-parity");
9950        let sink = seen.clone();
9951        agent.set_lifecycle_hook(Box::new(move |event| {
9952            sink.lock().unwrap().push(event.clone());
9953        }));
9954        agent.config.compact_after_messages = Some(10);
9955        assert!(agent.maybe_compact());
9956        assert!(matches!(
9957            seen.lock().unwrap()[0],
9958            LifecycleEvent::PreCompact { manual: false, .. }
9959        ));
9960        seen.lock().unwrap().clear();
9961        let mut small = Agent::with_provider(resolved("cc-parity"), Box::new(NoProvider));
9962        let sink = seen.clone();
9963        small.set_lifecycle_hook(Box::new(move |event| {
9964            sink.lock().unwrap().push(event.clone());
9965        }));
9966        assert!(!small.compact_now(None));
9967        assert!(seen.lock().unwrap().is_empty());
9968    }
9969
9970    /// With no summarizer installed the marker degrades to the count-only
9971    /// form — the side-call never blocks or fails compaction.
9972    #[test]
9973    fn compaction_without_a_summarizer_keeps_the_count_only_marker() {
9974        let mut agent = stuffed_agent("cc-parity");
9975        assert!(agent.compact_now(None));
9976        let marker = agent
9977            .history()
9978            .iter()
9979            .filter_map(|m| m.content.as_deref())
9980            .find(|c| c.contains("earlier conversation compacted"))
9981            .unwrap();
9982        assert!(!marker.contains("Summary of the compacted span"));
9983    }
9984
9985    /// Row `compaction-markers-persisted-in-transcript`: under the presets
9986    /// (reduction OFF) the boundary marker is written to the session
9987    /// sidecar, and says where the originals went.
9988    #[test]
9989    fn presets_persist_the_compaction_marker_to_the_transcript() {
9990        for preset in ["cc-parity", "cx-parity"] {
9991            let dir = scratch("marker");
9992            let path = dir.join("session.jsonl");
9993            let empty = crate::session::Session::from_claude_code_str("").unwrap();
9994            let writer = crate::sidecar::SidecarWriter::create(&path, &empty).unwrap();
9995            let mut agent = stuffed_agent(preset);
9996            agent.set_recorder(writer);
9997            assert!(agent.reduction_policy().is_none(), "{preset}");
9998
9999            assert!(agent.compact_now(None));
10000            let on_disk = std::fs::read_to_string(&path).unwrap();
10001            assert!(
10002                on_disk.contains("earlier conversation compacted"),
10003                "{preset}: the marker must reach the transcript on disk"
10004            );
10005            assert!(
10006                on_disk.contains("remain in this session's transcript sidecar"),
10007                "{preset}: the marker must say where the originals went"
10008            );
10009            let _ = std::fs::remove_dir_all(&dir);
10010        }
10011    }
10012
10013    /// Row `handoff-fresh-objective-curated-keep-set`: an in-session
10014    /// `new_context` — fresh objective, curated recent keep-set, persisted
10015    /// marker — under cx-parity, where `capabilities.reduction` is off.
10016    #[test]
10017    fn cx_parity_handoff_seeds_a_fresh_objective_with_a_curated_keep_set() {
10018        let mut agent = stuffed_agent("cx-parity");
10019        assert!(!agent.config().handoff_enabled, "reduction handoff is off");
10020        agent.history.push(ChatMessage::user("LAST-USER-TURN"));
10021        let before = agent.history().len();
10022
10023        let dropped = agent.new_context("ship the migration", Some(3));
10024        assert!(dropped > 0, "messages must be set aside");
10025        assert!(agent.history().len() < before);
10026        let system_prompt = agent.history()[0].content.clone().unwrap_or_default();
10027        assert!(
10028            system_prompt.contains("supercode") || !system_prompt.is_empty(),
10029            "the system prompt survives a handoff"
10030        );
10031        let marker = agent
10032            .history()
10033            .iter()
10034            .filter_map(|m| m.content.as_deref())
10035            .find(|c| c.contains("[handoff:"))
10036            .expect("handoff marker");
10037        assert!(marker.contains("Objective: ship the migration"));
10038        assert!(
10039            agent
10040                .history()
10041                .iter()
10042                .any(|m| m.content.as_deref() == Some("LAST-USER-TURN")),
10043            "the curated keep-set must carry the most recent turns"
10044        );
10045    }
10046
10047    /// Row `context-usage-introspection`: a live breakdown, from the same
10048    /// estimator the context guard enforces, without sending anything.
10049    #[test]
10050    fn presets_report_live_context_usage() {
10051        for preset in ["cc-parity", "cx-parity"] {
10052            let agent = stuffed_agent(preset);
10053            let usage = agent.context_usage();
10054            assert_eq!(usage.messages, agent.history().len(), "{preset}");
10055            assert!(usage.message_tokens > 0, "{preset}");
10056            assert_eq!(
10057                usage.request_tokens,
10058                usage.message_tokens + usage.tool_schema_tokens,
10059                "{preset}: the breakdown must add up"
10060            );
10061            assert!(usage.projected_tokens >= usage.request_tokens, "{preset}");
10062            assert!(usage.context_limit.is_some(), "{preset}: window known");
10063            assert!(usage.fits, "{preset}");
10064            let line = usage.summary_line();
10065            assert!(line.contains('%') && line.contains(&usage.model), "{line}");
10066
10067            // Pure: asking must not change what the next request carries.
10068            let again = agent.context_usage();
10069            assert_eq!(usage, again, "{preset}");
10070        }
10071    }
10072}
10073
10074#[cfg(test)]
10075#[path = "agent_bp10_tests.rs"]
10076mod bp10_permissions_tests;