Skip to main content

lean_ctx/server/
mod.rs

1pub mod bounded_lock;
2pub mod bypass_hint;
3pub mod compaction_sync;
4pub mod context_gate;
5mod dispatch;
6pub mod dynamic_tools;
7pub mod elicitation;
8pub(crate) mod execute;
9pub mod helpers;
10pub mod multi_path;
11pub mod notifications;
12pub mod progress;
13pub mod prompts;
14pub mod reference_store;
15pub mod registry;
16pub mod resources;
17pub mod role_guard;
18pub mod roots;
19use roots::has_project_marker;
20pub mod tool_trait;
21
22use futures::FutureExt;
23use rmcp::handler::server::ServerHandler;
24use rmcp::model::{
25    CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
26    InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
27};
28use rmcp::service::{RequestContext, RoleServer};
29use rmcp::ErrorData;
30
31use crate::tools::{CrpMode, LeanCtxServer};
32
33impl ServerHandler for LeanCtxServer {
34    fn get_info(&self) -> ServerInfo {
35        let capabilities = ServerCapabilities::builder()
36            .enable_tools()
37            .enable_resources()
38            .enable_resources_subscribe()
39            .enable_prompts()
40            .build();
41
42        let config = crate::core::config::Config::load();
43        let level = crate::core::config::CompressionLevel::effective(&config);
44        let _ = crate::core::terse::rules_inject::inject(&level);
45
46        let instructions = crate::instructions::build_instructions(CrpMode::effective());
47
48        InitializeResult::new(capabilities)
49            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
50            .with_instructions(instructions)
51    }
52
53    async fn initialize(
54        &self,
55        request: InitializeRequestParams,
56        context: RequestContext<RoleServer>,
57    ) -> Result<InitializeResult, ErrorData> {
58        let name = request.client_info.name.clone();
59        tracing::info!("MCP client connected: {:?}", name);
60        *self.client_name.write().await = name.clone();
61        *self.peer.write().await = Some(context.peer.clone());
62
63        if self.session_mode != crate::tools::SessionMode::Shared {
64            crate::core::budget_tracker::BudgetTracker::global().reset();
65            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
66                let radar = data_dir.join("context_radar.jsonl");
67                if radar.exists() {
68                    let prev = data_dir.join("context_radar.prev.jsonl");
69                    let _ = std::fs::rename(&radar, &prev);
70                }
71            }
72        }
73
74        let has_roots = request.capabilities.roots.is_some();
75        self.has_client_roots
76            .store(has_roots, std::sync::atomic::Ordering::Relaxed);
77        if has_roots {
78            tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
79        }
80
81        let env_root = roots::root_from_env();
82        let derived_root = derive_project_root_from_cwd();
83        let effective_root = env_root.or(derived_root);
84
85        let cwd_str = std::env::current_dir()
86            .ok()
87            .map(|p| p.to_string_lossy().to_string())
88            .unwrap_or_default();
89        {
90            let mut session = self.session.write().await;
91            if !cwd_str.is_empty() {
92                session.shell_cwd = Some(cwd_str.clone());
93            }
94            if let Some(ref root) = effective_root {
95                session.project_root = Some(root.clone());
96                tracing::info!("Project root set to: {root}");
97            } else if let Some(ref root) = session.project_root {
98                let root_path = std::path::Path::new(root);
99                let root_has_marker = has_project_marker(root_path);
100                let root_str = root_path.to_string_lossy();
101                let root_suspicious = root_str.contains("/.claude")
102                    || root_str.contains("/.codex")
103                    || root_str.contains("/var/folders/")
104                    || root_str.contains("/tmp/")
105                    || root_str.contains("\\.claude")
106                    || root_str.contains("\\.codex")
107                    || root_str.contains("\\AppData\\Local\\Temp")
108                    || root_str.contains("\\Temp\\");
109                if root_suspicious && !root_has_marker {
110                    session.project_root = None;
111                }
112            }
113            let cfg_extra = crate::core::config::Config::load().extra_roots;
114            if !cfg_extra.is_empty() {
115                let existing: std::collections::HashSet<_> =
116                    session.extra_roots.iter().cloned().collect();
117                for r in cfg_extra {
118                    if !existing.contains(&r) {
119                        session.extra_roots.push(r);
120                    }
121                }
122            }
123            if self.session_mode == crate::tools::SessionMode::Shared {
124                if let Some(ref root) = session.project_root {
125                    if let Some(ref rt) = self.context_os {
126                        rt.shared_sessions.persist_best_effort(
127                            root,
128                            &self.workspace_id,
129                            &self.channel_id,
130                            &session,
131                        );
132                        rt.metrics.record_session_persisted();
133                    }
134                }
135            } else if let Err(e) = session.save() {
136                tracing::warn!("lean-ctx: failed to persist session state: {e}");
137            }
138        }
139
140        if let Some(ref root) = effective_root {
141            crate::core::index_orchestrator::ensure_all_background(root);
142        }
143
144        let agent_name = name.clone();
145        let agent_root = effective_root.clone().unwrap_or_default();
146        let agent_id_handle = self.agent_id.clone();
147        tokio::task::spawn_blocking(move || {
148            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
149                return;
150            }
151
152            // Avoid startup stampedes when multiple agent sessions initialize at once.
153            // These are best-effort maintenance tasks; it's fine to skip if another
154            // lean-ctx instance is already doing them.
155            let maintenance = crate::core::startup_guard::try_acquire_lock(
156                "startup-maintenance",
157                std::time::Duration::from_secs(2),
158                std::time::Duration::from_mins(2),
159            );
160            if maintenance.is_some() {
161                if let Some(home) = dirs::home_dir() {
162                    let _ = crate::rules_inject::inject_all_rules(&home);
163                }
164                crate::hooks::refresh_installed_hooks();
165                crate::core::version_check::check_background();
166            }
167            drop(maintenance);
168
169            if !agent_root.is_empty() {
170                let heuristic_role = match agent_name.to_lowercase().as_str() {
171                    n if n.contains("cursor") => Some("coder"),
172                    n if n.contains("claude") => Some("coder"),
173                    n if n.contains("codex") => Some("coder"),
174                    n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
175                    n if n.contains("review") => Some("reviewer"),
176                    n if n.contains("test") => Some("debugger"),
177                    _ => None,
178                };
179                let env_role = std::env::var("LEAN_CTX_ROLE")
180                    .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
181                    .ok();
182                let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
183
184                let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
185
186                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
187                registry.cleanup_stale(24);
188                let id = registry.register("mcp", Some(effective_role), &agent_root);
189                let _ = registry.save();
190                if let Ok(mut guard) = agent_id_handle.try_write() {
191                    *guard = Some(id);
192                }
193            }
194        });
195
196        let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
197        tracing::info!("Client capabilities: {}", client_caps.format_summary());
198
199        {
200            let cfg = crate::core::config::Config::load();
201            let cats = cfg.default_tool_categories_effective();
202            dynamic_tools::init_from_config(&cats);
203        }
204
205        if client_caps.dynamic_tools {
206            if let Ok(mut dt) = dynamic_tools::global().lock() {
207                dt.set_supports_list_changed(true);
208            }
209        }
210        if let Some(max) = client_caps.max_tools {
211            if let Ok(mut dt) = dynamic_tools::global().lock() {
212                dt.set_supports_list_changed(true);
213                if max < 100 {
214                    dt.unload_category(dynamic_tools::ToolCategory::Debug);
215                    dt.unload_category(dynamic_tools::ToolCategory::Memory);
216                }
217            }
218        }
219
220        crate::core::client_capabilities::set_detected(&client_caps);
221
222        let instructions =
223            crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
224
225        let capabilities = match (client_caps.resources, client_caps.prompts) {
226            (true, true) => ServerCapabilities::builder()
227                .enable_tools()
228                .enable_resources()
229                .enable_resources_subscribe()
230                .enable_prompts()
231                .build(),
232            (true, false) => ServerCapabilities::builder()
233                .enable_tools()
234                .enable_resources()
235                .enable_resources_subscribe()
236                .build(),
237            (false, true) => ServerCapabilities::builder()
238                .enable_tools()
239                .enable_prompts()
240                .build(),
241            (false, false) => ServerCapabilities::builder().enable_tools().build(),
242        };
243
244        Ok(InitializeResult::new(capabilities)
245            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
246            .with_instructions(instructions))
247    }
248
249    async fn list_tools(
250        &self,
251        _request: Option<PaginatedRequestParams>,
252        _context: RequestContext<RoleServer>,
253    ) -> Result<ListToolsResult, ErrorData> {
254        let all_tools = if crate::tool_defs::is_full_mode() {
255            if let Some(ref reg) = self.registry {
256                reg.tool_defs()
257            } else {
258                crate::tool_defs::granular_tool_defs()
259            }
260        } else if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
261            crate::tool_defs::unified_tool_defs()
262        } else if let Some(ref reg) = self.registry {
263            let core_names = crate::tool_defs::core_tool_names();
264            reg.tool_defs()
265                .into_iter()
266                .filter(|t| core_names.contains(&t.name.as_ref()))
267                .collect()
268        } else {
269            crate::tool_defs::lazy_tool_defs()
270        };
271
272        let disabled = crate::core::config::Config::load().disabled_tools_effective();
273        let client = self.client_name.read().await.clone();
274        let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
275
276        let active_role = crate::core::roles::active_role();
277        let tools: Vec<_> = all_tools
278            .into_iter()
279            .filter(|t| {
280                let name = t.name.as_ref();
281                if !disabled.is_empty() && disabled.iter().any(|d| d.as_str() == name) {
282                    return false;
283                }
284                if is_zed && name == "ctx_edit" {
285                    return false;
286                }
287                if !active_role.is_tool_allowed(name) {
288                    return false;
289                }
290                true
291            })
292            .collect();
293
294        let tools = {
295            let Ok(dyn_state) = dynamic_tools::global().lock() else {
296                tracing::warn!("dynamic_tools mutex poisoned in list_tools; returning unfiltered");
297                return Ok(ListToolsResult {
298                    tools,
299                    ..Default::default()
300                });
301            };
302            if dyn_state.supports_list_changed() {
303                tools
304                    .into_iter()
305                    .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
306                    .collect()
307            } else {
308                tools
309            }
310        };
311
312        let tools = {
313            let active = self.workflow.read().await.clone();
314            if let Some(run) = active {
315                if run.current == "done" || is_workflow_stale(&run) {
316                    let mut wf = self.workflow.write().await;
317                    *wf = None;
318                    let _ = crate::core::workflow::clear_active();
319                } else if let Some(state) = run.spec.state(&run.current) {
320                    if let Some(allowed) = &state.allowed_tools {
321                        let mut allow: std::collections::HashSet<&str> =
322                            allowed.iter().map(std::string::String::as_str).collect();
323                        for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
324                            allow.insert(passthrough);
325                        }
326                        return Ok(ListToolsResult {
327                            tools: tools
328                                .into_iter()
329                                .filter(|t| allow.contains(t.name.as_ref()))
330                                .collect(),
331                            ..Default::default()
332                        });
333                    }
334                }
335            }
336            tools
337        };
338
339        let tools = {
340            let cfg = crate::core::config::Config::load();
341            let level = crate::core::config::CompressionLevel::effective(&cfg);
342            let mode =
343                crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
344            if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
345                tools
346            } else {
347                tools
348                    .into_iter()
349                    .map(|mut t| {
350                        let compressed = crate::core::terse::mcp_compress::compress_description(
351                            t.name.as_ref(),
352                            t.description.as_deref().unwrap_or(""),
353                            mode,
354                        );
355                        t.description = Some(compressed.into());
356                        t
357                    })
358                    .collect()
359            }
360        };
361
362        Ok(ListToolsResult {
363            tools,
364            ..Default::default()
365        })
366    }
367
368    async fn list_prompts(
369        &self,
370        _request: Option<PaginatedRequestParams>,
371        _context: RequestContext<RoleServer>,
372    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
373        Ok(rmcp::model::ListPromptsResult::with_all_items(
374            prompts::list_prompts(),
375        ))
376    }
377
378    async fn get_prompt(
379        &self,
380        request: rmcp::model::GetPromptRequestParams,
381        _context: RequestContext<RoleServer>,
382    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
383        let ledger = self.ledger.read().await;
384        match prompts::get_prompt(&request, &ledger) {
385            Some(result) => Ok(result),
386            None => Err(ErrorData::invalid_params(
387                format!("Unknown prompt: {}", request.name),
388                None,
389            )),
390        }
391    }
392
393    async fn list_resources(
394        &self,
395        _request: Option<PaginatedRequestParams>,
396        _context: RequestContext<RoleServer>,
397    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
398        Ok(rmcp::model::ListResourcesResult::with_all_items(
399            resources::list_resources(),
400        ))
401    }
402
403    async fn read_resource(
404        &self,
405        request: rmcp::model::ReadResourceRequestParams,
406        _context: RequestContext<RoleServer>,
407    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
408        let ledger = self.ledger.read().await;
409        match resources::read_resource(&request.uri, &ledger) {
410            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
411            None => Err(rmcp::ErrorData::resource_not_found(
412                format!("Unknown resource: {}", request.uri),
413                None,
414            )),
415        }
416    }
417
418    async fn call_tool(
419        &self,
420        request: CallToolRequestParams,
421        context: RequestContext<RoleServer>,
422    ) -> Result<CallToolResult, ErrorData> {
423        use std::panic::AssertUnwindSafe;
424
425        let progress_token = request
426            .meta
427            .as_ref()
428            .and_then(rmcp::model::Meta::get_progress_token);
429        if let Some(ref token) = progress_token {
430            let sender =
431                crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
432            *self
433                .progress_sender
434                .lock()
435                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
436        }
437
438        let tool_name_for_panic = request.name.as_ref().to_string();
439        let args_fp_for_panic = request
440            .arguments
441            .as_ref()
442            .map(|a| {
443                crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
444                    a.clone(),
445                ))
446            })
447            .unwrap_or_default();
448
449        let loop_detector = self.loop_detector.clone();
450
451        match AssertUnwindSafe(self.call_tool_guarded(request))
452            .catch_unwind()
453            .await
454        {
455            Ok(result) => result,
456            Err(panic_payload) => {
457                let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
458                    (*s).to_string()
459                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
460                    s.clone()
461                } else {
462                    "unknown".to_string()
463                };
464                tracing::error!("call_tool panicked: {detail}");
465
466                if let Ok(mut detector) =
467                    tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
468                        .await
469                {
470                    detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
471                }
472
473                Ok(CallToolResult::error(vec![Content::text(
474                    "ERROR: lean-ctx internal error. The MCP server is still running. \
475                     Please retry or use a different approach."
476                        .to_string(),
477                )]))
478            }
479        }
480    }
481
482    async fn on_roots_list_changed(
483        &self,
484        _context: rmcp::service::NotificationContext<RoleServer>,
485    ) {
486        tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
487        self.roots_resolved
488            .store(false, std::sync::atomic::Ordering::Relaxed);
489    }
490}
491
492impl LeanCtxServer {
493    async fn call_tool_guarded(
494        &self,
495        request: CallToolRequestParams,
496    ) -> Result<CallToolResult, ErrorData> {
497        self.check_idle_expiry().await;
498        self.resolve_roots_once().await;
499        elicitation::increment_call();
500
501        let original_name = request.name.as_ref().to_string();
502        let (resolved_name, resolved_args) = if original_name == "ctx" {
503            let sub = request
504                .arguments
505                .as_ref()
506                .and_then(|a| a.get("tool"))
507                .and_then(|v| v.as_str())
508                .map(std::string::ToString::to_string)
509                .ok_or_else(|| {
510                    ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
511                })?;
512            let tool_name = if sub.starts_with("ctx_") {
513                sub
514            } else {
515                format!("ctx_{sub}")
516            };
517            let mut args = request.arguments.unwrap_or_default();
518            args.remove("tool");
519            (tool_name, Some(args))
520        } else {
521            (original_name, request.arguments)
522        };
523        let name = resolved_name.as_str();
524        let args = resolved_args.as_ref();
525
526        let role_check = role_guard::check_tool_access(name);
527        if let Some(denied) = role_guard::into_call_tool_result(&role_check) {
528            tracing::warn!(
529                tool = name,
530                role = %role_check.role_name,
531                "Tool blocked by role policy"
532            );
533            return Ok(denied);
534        }
535
536        if name != "ctx_workflow" {
537            let active = self.workflow.read().await.clone();
538            if let Some(run) = active {
539                if run.current == "done" || is_workflow_stale(&run) {
540                    let mut wf = self.workflow.write().await;
541                    *wf = None;
542                    let _ = crate::core::workflow::clear_active();
543                } else if !WORKFLOW_PASSTHROUGH_TOOLS.contains(&name) {
544                    if let Some(state) = run.spec.state(&run.current) {
545                        if let Some(allowed) = &state.allowed_tools {
546                            let allowed_ok = allowed.iter().any(|t| t == name);
547                            if !allowed_ok {
548                                let mut shown = allowed.clone();
549                                shown.sort();
550                                shown.truncate(30);
551                                return Ok(CallToolResult::success(vec![Content::text(format!(
552                                    "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed: {}. Use ctx_workflow(action=\"stop\") to exit.",
553                                    run.spec.name,
554                                    run.current,
555                                    shown.join(", ")
556                                ))]));
557                            }
558                        }
559                    }
560                }
561            }
562        }
563
564        let auto_context = {
565            let task = {
566                let session = self.session.read().await;
567                session.task.as_ref().map(|t| t.description.clone())
568            };
569            let project_root = {
570                let session = self.session.read().await;
571                session.project_root.clone()
572            };
573            let cache_timeout =
574                tokio::time::timeout(std::time::Duration::from_secs(5), self.cache.write()).await;
575            if let Ok(mut cache) = cache_timeout {
576                crate::tools::autonomy::session_lifecycle_pre_hook(
577                    &self.autonomy,
578                    name,
579                    &mut cache,
580                    task.as_deref(),
581                    project_root.as_deref(),
582                    CrpMode::effective(),
583                )
584            } else {
585                tracing::warn!("pre-dispatch: cache write-lock timeout (5s), skipping autonomy");
586                None
587            }
588        };
589
590        let args_fp = args
591            .map(|a| {
592                crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
593                    a.clone(),
594                ))
595            })
596            .unwrap_or_default();
597        let throttle_result = {
598            let fp = &args_fp;
599            let detector_timeout = tokio::time::timeout(
600                std::time::Duration::from_secs(3),
601                self.loop_detector.write(),
602            )
603            .await;
604            if let Ok(mut detector) = detector_timeout {
605                let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
606                let is_search_shell = name == "ctx_shell" && {
607                    let cmd = args
608                        .as_ref()
609                        .and_then(|a| a.get("command"))
610                        .and_then(|v| v.as_str())
611                        .unwrap_or("");
612                    crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
613                };
614
615                if is_search || is_search_shell {
616                    let search_pattern = args.and_then(|a| {
617                        a.get("pattern")
618                            .or_else(|| a.get("query"))
619                            .and_then(|v| v.as_str())
620                    });
621                    let shell_pattern = if is_search_shell {
622                        args.and_then(|a| a.get("command"))
623                            .and_then(|v| v.as_str())
624                            .and_then(helpers::extract_search_pattern_from_command)
625                    } else {
626                        None
627                    };
628                    let pat = search_pattern.or(shell_pattern.as_deref());
629                    detector.record_search(name, fp, pat)
630                } else {
631                    detector.record_call(name, fp)
632                }
633            } else {
634                tracing::warn!("pre-dispatch: loop_detector write-lock timeout (3s), skipping");
635                crate::core::loop_detection::ThrottleResult::default()
636            }
637        };
638
639        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
640            let msg = throttle_result.message.unwrap_or_default();
641            return Ok(CallToolResult::success(vec![Content::text(msg)]));
642        }
643
644        let throttle_warning =
645            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
646                throttle_result.message.clone()
647            } else {
648                None
649            };
650
651        let config = crate::core::config::Config::load();
652        let minimal = config.minimal_overhead_effective();
653
654        {
655            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
656            let snap = BudgetTracker::global().check();
657            if *snap.worst_level() == BudgetLevel::Exhausted
658                && name != "ctx_session"
659                && name != "ctx_cost"
660                && name != "ctx_metrics"
661            {
662                for (dim, lvl, used, limit) in [
663                    (
664                        "tokens",
665                        &snap.tokens.level,
666                        format!("{}", snap.tokens.used),
667                        format!("{}", snap.tokens.limit),
668                    ),
669                    (
670                        "shell",
671                        &snap.shell.level,
672                        format!("{}", snap.shell.used),
673                        format!("{}", snap.shell.limit),
674                    ),
675                    (
676                        "cost",
677                        &snap.cost.level,
678                        format!("${:.2}", snap.cost.used_usd),
679                        format!("${:.2}", snap.cost.limit_usd),
680                    ),
681                ] {
682                    if *lvl == BudgetLevel::Exhausted {
683                        crate::core::events::emit_budget_exhausted(&snap.role, dim, &used, &limit);
684                    }
685                }
686                let msg = format!(
687                    "[BUDGET EXHAUSTED] {}\n\
688                     Use `ctx_session action=role` to check/switch roles, \
689                     or `ctx_session action=reset` to start fresh.",
690                    snap.format_compact()
691                );
692                tracing::warn!(tool = name, "{msg}");
693                return Ok(CallToolResult::success(vec![Content::text(msg)]));
694            }
695        }
696
697        if is_shell_tool_name(name) {
698            crate::core::budget_tracker::BudgetTracker::global().record_shell();
699        }
700
701        let tool_start = std::time::Instant::now();
702        let (mut result_text, tool_saved_tokens) =
703            match self.dispatch_tool(name, args, minimal).await {
704                Ok(pair) => pair,
705                Err(e) => {
706                    if let Ok(mut detector) = tokio::time::timeout(
707                        std::time::Duration::from_secs(1),
708                        self.loop_detector.write(),
709                    )
710                    .await
711                    {
712                        detector.record_error_outcome(name, &args_fp);
713                    }
714                    return Err(e);
715                }
716            };
717
718        let is_raw_shell = name == "ctx_shell" && {
719            let arg_raw = helpers::get_bool(args, "raw").unwrap_or(false);
720            let arg_bypass = helpers::get_bool(args, "bypass").unwrap_or(false);
721            arg_raw
722                || arg_bypass
723                || std::env::var("LEAN_CTX_DISABLED").is_ok()
724                || std::env::var("LEAN_CTX_RAW").is_ok()
725        };
726
727        let pre_terse_len = result_text.len();
728        let output_tokens = {
729            let tokens = crate::core::tokens::count_tokens(&result_text) as u64;
730            crate::core::budget_tracker::BudgetTracker::global().record_tokens(tokens);
731            tokens
732        };
733
734        crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
735
736        // Context IR: record lineage for every tool call.
737        if let Some(ref ir) = self.context_ir {
738            let tool_duration = tool_start.elapsed();
739            let source_kind = match name {
740                n if n.contains("read") || n.contains("multi_read") || n.contains("smart_read") => {
741                    crate::core::context_ir::ContextIrSourceKindV1::Read
742                }
743                "ctx_shell" => crate::core::context_ir::ContextIrSourceKindV1::Shell,
744                "ctx_search" | "ctx_semantic_search" => {
745                    crate::core::context_ir::ContextIrSourceKindV1::Search
746                }
747                "ctx_provider" => crate::core::context_ir::ContextIrSourceKindV1::Provider,
748                _ => crate::core::context_ir::ContextIrSourceKindV1::Other,
749            };
750            let ir_path = helpers::get_str(args, "path");
751            let ir_command = helpers::get_str(args, "command");
752            let ir_mode = helpers::get_str(args, "mode");
753            let excerpt = if result_text.len() > 200 {
754                let mut end = 200;
755                while !result_text.is_char_boundary(end) && end > 0 {
756                    end -= 1;
757                }
758                &result_text[..end]
759            } else {
760                &result_text
761            };
762            let input = crate::core::context_ir::RecordIrInput {
763                kind: source_kind,
764                tool: name,
765                client_name: None,
766                agent_id: None,
767                path: ir_path.as_deref(),
768                command: ir_command.as_deref(),
769                pattern: ir_mode.as_deref(),
770                input_tokens: pre_terse_len / 4,
771                output_tokens: output_tokens as usize,
772                duration: tool_duration,
773                content_excerpt: excerpt,
774            };
775            ir.write().await.record(input);
776        }
777
778        // Correction-loop detection: track re-reads and re-runs as quality signals.
779        {
780            let mut detector = self.loop_detector.write().await;
781            if name == "ctx_read" {
782                let path = helpers::get_str(args, "path").unwrap_or_default();
783                let mode = helpers::get_str(args, "mode").unwrap_or_else(|| "auto".into());
784                let fresh = helpers::get_bool(args, "fresh").unwrap_or(false);
785                detector.record_read_for_correction(&path, &mode, fresh);
786            } else if name == "ctx_shell" {
787                let cmd = helpers::get_str(args, "command").unwrap_or_default();
788                detector.record_shell_for_correction(&cmd);
789            }
790            let correction_count = detector.correction_count();
791            if correction_count > 0 {
792                crate::core::anomaly::record_metric(
793                    "correction_loop_rate",
794                    f64::from(correction_count),
795                );
796            }
797            // Auto-degrade: reduce compression when correction rate is high
798            use crate::core::config::CompressionLevel;
799            if correction_count >= 5 {
800                CompressionLevel::set_session_degrade(&CompressionLevel::Off);
801            } else if correction_count >= 3 {
802                CompressionLevel::set_session_degrade(&CompressionLevel::Lite);
803            } else if correction_count == 0 {
804                CompressionLevel::clear_session_degrade();
805            }
806            detector.prune_corrections();
807        }
808
809        // Persist anomaly detector — debounced to reduce I/O in burst sequences.
810        crate::core::anomaly::save_debounced();
811
812        let budget_warning = {
813            use crate::core::budget_tracker::{BudgetLevel, BudgetTracker};
814            let snap = BudgetTracker::global().check();
815            if *snap.worst_level() == BudgetLevel::Warning {
816                for (dim, lvl, used, limit, pct) in [
817                    (
818                        "tokens",
819                        &snap.tokens.level,
820                        format!("{}", snap.tokens.used),
821                        format!("{}", snap.tokens.limit),
822                        snap.tokens.percent,
823                    ),
824                    (
825                        "shell",
826                        &snap.shell.level,
827                        format!("{}", snap.shell.used),
828                        format!("{}", snap.shell.limit),
829                        snap.shell.percent,
830                    ),
831                    (
832                        "cost",
833                        &snap.cost.level,
834                        format!("${:.2}", snap.cost.used_usd),
835                        format!("${:.2}", snap.cost.limit_usd),
836                        snap.cost.percent,
837                    ),
838                ] {
839                    if *lvl == BudgetLevel::Warning {
840                        crate::core::events::emit_budget_warning(
841                            &snap.role, dim, &used, &limit, pct,
842                        );
843                    }
844                }
845                if crate::core::protocol::meta_visible() {
846                    Some(format!("[BUDGET WARNING] {}", snap.format_compact()))
847                } else {
848                    None
849                }
850            } else {
851                None
852            }
853        };
854
855        let archive_hint = if minimal || is_raw_shell {
856            None
857        } else {
858            use crate::core::archive;
859            let archivable = matches!(
860                name,
861                "ctx_shell"
862                    | "ctx_read"
863                    | "ctx_multi_read"
864                    | "ctx_smart_read"
865                    | "ctx_execute"
866                    | "ctx_search"
867                    | "ctx_tree"
868            );
869            if archivable && archive::should_archive(&result_text) {
870                let cmd = helpers::get_str(args, "command")
871                    .or_else(|| helpers::get_str(args, "path"))
872                    .unwrap_or_default();
873                let session_id = self.session.read().await.id.clone();
874                let to_store = crate::core::redaction::redact_text_if_enabled(&result_text);
875                let tokens = crate::core::tokens::count_tokens(&to_store);
876                archive::store(name, &cmd, &to_store, Some(&session_id))
877                    .map(|id| archive::format_hint(&id, to_store.len(), tokens))
878            } else {
879                None
880            }
881        };
882
883        let pre_compression = result_text.clone();
884        let deeply_compressed = matches!(
885            name,
886            "ctx_read" | "ctx_multi_read" | "ctx_smart_read" | "ctx_compress" | "ctx_overview"
887        );
888        let skip_terse = is_raw_shell
889            || (tool_saved_tokens > 0 && deeply_compressed)
890            || (name == "ctx_shell"
891                && helpers::get_str(args, "command")
892                    .is_some_and(|c| crate::shell::compress::has_structural_output(&c)));
893        let compression = crate::core::config::CompressionLevel::effective(&config);
894        if compression.is_active() && !skip_terse {
895            let terse_result =
896                crate::core::terse::pipeline::compress(&result_text, &compression, None);
897            if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
898                result_text = terse_result.output;
899            }
900        }
901
902        let profile_hints = crate::core::profiles::active_profile().output_hints;
903
904        if !is_raw_shell && profile_hints.verify_footer() {
905            let verify_cfg = crate::core::profiles::active_profile().verification;
906            let vr = crate::core::output_verification::verify_output(
907                &pre_compression,
908                &result_text,
909                &verify_cfg,
910            );
911            if !vr.warnings.is_empty() {
912                let msg = format!("[VERIFY] {}", vr.format_compact());
913                result_text = format!("{result_text}\n\n{msg}");
914            }
915        }
916
917        if profile_hints.archive_hint() {
918            if let Some(hint) = archive_hint {
919                result_text = format!("{result_text}\n{hint}");
920            }
921        }
922
923        if !is_raw_shell {
924            if let Some(ctx) = auto_context {
925                let ctx_tokens = crate::core::tokens::count_tokens(&ctx);
926                if ctx_tokens <= 400 {
927                    result_text = format!("{ctx}\n\n{result_text}");
928                }
929            }
930        }
931
932        if let Some(warning) = throttle_warning {
933            result_text = format!("{result_text}\n\n{warning}");
934        }
935
936        if let Some(bw) = budget_warning {
937            result_text = format!("{result_text}\n\n{bw}");
938        }
939
940        if !self
941            .rules_stale_checked
942            .swap(true, std::sync::atomic::Ordering::Relaxed)
943        {
944            let client = self.client_name.read().await.clone();
945            if !client.is_empty() {
946                if let Some(stale_msg) = crate::rules_inject::check_rules_freshness(&client) {
947                    result_text = format!("{result_text}\n\n{stale_msg}");
948                }
949            }
950        }
951
952        {
953            // Evaluate SLOs for observability (watch/dashboard), but keep tool outputs clean.
954            let _ = crate::core::slo::evaluate();
955        }
956
957        if name == "ctx_read" {
958            if minimal {
959                let cache_clone = self.cache.clone();
960                let autonomy_clone = self.autonomy.clone();
961                let name_owned = name.to_string();
962                tokio::spawn(async move {
963                    let result = std::panic::AssertUnwindSafe(async {
964                        let mut cache = cache_clone.write().await;
965                        crate::tools::autonomy::maybe_auto_dedup(
966                            &autonomy_clone,
967                            &mut cache,
968                            &name_owned,
969                        );
970                    })
971                    .catch_unwind()
972                    .await;
973                    if let Err(e) = result {
974                        let msg = e
975                            .downcast_ref::<String>()
976                            .map(String::as_str)
977                            .or_else(|| e.downcast_ref::<&str>().copied())
978                            .unwrap_or("unknown");
979                        tracing::error!("background auto_dedup panicked: {msg}");
980                    }
981                });
982            } else {
983                let read_path = self
984                    .resolve_path_or_passthrough(
985                        &helpers::get_str(args, "path").unwrap_or_default(),
986                    )
987                    .await;
988                let project_root = {
989                    let session = self.session.read().await;
990                    session.project_root.clone()
991                };
992
993                // Bounded cache lock for enrichment — degrade gracefully under contention
994                let enrich_timeout =
995                    tokio::time::timeout(std::time::Duration::from_secs(3), self.cache.write())
996                        .await;
997                if let Ok(mut cache) = enrich_timeout {
998                    let enrich = crate::tools::autonomy::enrich_after_read(
999                        &self.autonomy,
1000                        &mut cache,
1001                        &read_path,
1002                        project_root.as_deref(),
1003                        None,
1004                        crate::tools::CrpMode::effective(),
1005                        false,
1006                    );
1007                    if profile_hints.related_hint() {
1008                        if let Some(hint) = enrich.related_hint {
1009                            result_text = format!("{result_text}\n{hint}");
1010                        }
1011                    }
1012                    crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache, name);
1013                } else {
1014                    tracing::warn!(
1015                        "post-dispatch cache lock timeout (3s) for {read_path}, skipping enrichment"
1016                    );
1017                }
1018
1019                // Ledger update — fire-and-forget to avoid blocking concurrent reads
1020                let ledger_clone = self.ledger.clone();
1021                let session_clone = self.session.clone();
1022                let peer_clone = self.peer.clone();
1023                let read_path_owned = read_path.clone();
1024                let project_root_owned = project_root.clone();
1025                let mode_used =
1026                    helpers::get_str(args, "mode").unwrap_or_else(|| "auto".to_string());
1027                let out_tok = output_tokens as usize;
1028                let sent_tok = crate::core::tokens::count_tokens(&result_text);
1029                let wants_eviction = true;
1030                let wants_elicitation = profile_hints.elicitation_hint();
1031                tokio::spawn(async move {
1032                    let result = std::panic::AssertUnwindSafe(async {
1033                        let active_task = {
1034                            let session = session_clone.read().await;
1035                            session.task.as_ref().map(|t| t.description.clone())
1036                        };
1037                        let mut ledger = ledger_clone.write().await;
1038                        let overlay = crate::core::context_overlay::OverlayStore::load_project(
1039                            &std::path::PathBuf::from(project_root_owned.as_deref().unwrap_or(".")),
1040                        );
1041                        let gate_result = context_gate::post_dispatch_record_with_task(
1042                            &read_path_owned,
1043                            &mode_used,
1044                            out_tok,
1045                            sent_tok,
1046                            &mut ledger,
1047                            &overlay,
1048                            active_task.as_deref(),
1049                        );
1050                        drop(ledger);
1051                        if wants_eviction {
1052                            if let Some(hint) = &gate_result.eviction_hint {
1053                                tracing::debug!("deferred eviction hint: {hint}");
1054                            }
1055                        }
1056                        if wants_elicitation {
1057                            if let Some(hint) = &gate_result.elicitation_hint {
1058                                tracing::debug!("deferred elicitation hint: {hint}");
1059                            }
1060                        }
1061                        if gate_result.resource_changed {
1062                            if let Some(peer) = peer_clone.read().await.as_ref() {
1063                                notifications::send_resource_updated(
1064                                    peer,
1065                                    notifications::RESOURCE_URI_SUMMARY,
1066                                )
1067                                .await;
1068                            }
1069                        }
1070                    })
1071                    .catch_unwind()
1072                    .await;
1073                    if let Err(e) = result {
1074                        let msg = e
1075                            .downcast_ref::<String>()
1076                            .map(String::as_str)
1077                            .or_else(|| e.downcast_ref::<&str>().copied())
1078                            .unwrap_or("unknown");
1079                        tracing::error!("background post_dispatch panicked: {msg}");
1080                    }
1081                });
1082            }
1083        }
1084
1085        if !minimal && !is_raw_shell && name == "ctx_shell" {
1086            let cmd = helpers::get_str(args, "command").unwrap_or_default();
1087
1088            if let Some(file_path) = extract_file_read_from_shell(&cmd) {
1089                if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
1090                    bt.next_seq();
1091                    bt.record_shell_file_access(&file_path);
1092                }
1093            }
1094
1095            if profile_hints.efficiency_hint() {
1096                let calls = self.tool_calls.read().await;
1097                let last_original = calls.last().map_or(0, |c| c.original_tokens);
1098                drop(calls);
1099                let pre_hint_tokens = crate::core::tokens::count_tokens(&result_text);
1100                if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
1101                    &self.autonomy,
1102                    &cmd,
1103                    last_original,
1104                    pre_hint_tokens,
1105                ) {
1106                    result_text = format!("{result_text}\n{hint}");
1107                }
1108            }
1109        }
1110
1111        if !minimal && !is_raw_shell {
1112            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
1113                let session = self.session.read().await;
1114                bypass_hint::set_session_id(&session.id);
1115                drop(session);
1116                if let Some(hint) = bypass_hint::check(&data_dir) {
1117                    result_text = format!("{result_text}\n{hint}");
1118                }
1119            }
1120            bypass_hint::record_lctx_call();
1121        }
1122
1123        if let Some(finding) = crate::core::auto_findings::extract(name, &result_text) {
1124            let mut session = self.session.write().await;
1125            session.add_finding(finding.file.as_deref(), None, &finding.summary);
1126            let project_root = session.project_root.clone();
1127            drop(session);
1128            if let Some(ref root) = project_root {
1129                let f = finding.clone();
1130                let r = root.clone();
1131                std::thread::spawn(move || {
1132                    crate::core::auto_capture::capture_finding(&r, &f);
1133                });
1134            }
1135        }
1136        if let Some(extra) = crate::core::auto_capture::extract_extra(name, &result_text) {
1137            let session = self.session.read().await;
1138            let project_root = session.project_root.clone();
1139            drop(session);
1140            if let Some(ref root) = project_root {
1141                let e = extra.clone();
1142                let r = root.clone();
1143                std::thread::spawn(move || {
1144                    crate::core::auto_capture::capture_finding(&r, &e);
1145                });
1146            }
1147        }
1148
1149        {
1150            let tool_name = name.to_string();
1151            let summary = result_text.lines().next().unwrap_or("").to_string();
1152            std::thread::spawn(move || {
1153                crate::core::journal::maybe_day_separator();
1154                crate::core::journal::log_tool_call(&tool_name, &summary);
1155            });
1156        }
1157
1158        #[allow(clippy::cast_possible_truncation)]
1159        let output_token_count = if result_text.len() == pre_terse_len {
1160            output_tokens as usize
1161        } else {
1162            crate::core::tokens::count_tokens(&result_text)
1163        };
1164
1165        // OPT-4: Correct stats with post-processing token counts.
1166        // dispatch/mod.rs records savings before terse/hints; adjust here
1167        // so persistent stats reflect what the model actually receives.
1168        if result_text.len() != pre_terse_len && tool_saved_tokens > 0 {
1169            let pre_savings = tool_saved_tokens;
1170            let actual_sent = output_token_count;
1171            let original = actual_sent + pre_savings;
1172            let actual_savings = original.saturating_sub(actual_sent);
1173            if actual_savings != pre_savings {
1174                let delta = pre_savings as i64 - actual_savings as i64;
1175                if delta != 0 {
1176                    crate::core::stats::adjust_savings(name, delta);
1177                }
1178            }
1179        }
1180
1181        let action = helpers::get_str(args, "action");
1182
1183        // K-bounded staleness guard: warn if shared context has diverged.
1184        const K_STALENESS_BOUND: i64 = 10;
1185        if self.session_mode == crate::tools::SessionMode::Shared {
1186            if let Some(ref rt) = self.context_os {
1187                let latest = rt.bus.latest_id(&self.workspace_id, &self.channel_id);
1188                let cursor = self
1189                    .last_seen_event_id
1190                    .load(std::sync::atomic::Ordering::Relaxed);
1191                if cursor > 0 && latest - cursor > K_STALENESS_BOUND {
1192                    let gap = latest - cursor;
1193                    result_text = format!(
1194                        "[CONTEXT STALE] {gap} events happened since your last read. \
1195                         Use ctx_session(action=\"status\") to sync.\n\n{result_text}"
1196                    );
1197                }
1198                self.last_seen_event_id
1199                    .store(latest, std::sync::atomic::Ordering::Relaxed);
1200            }
1201        }
1202
1203        {
1204            let input = helpers::canonical_args_string(args);
1205            let input_md5 = helpers::hash_fast(&input);
1206            let output_md5 = helpers::hash_fast(&result_text);
1207            let agent_id = self.agent_id.read().await.clone();
1208            let client_name = self.client_name.read().await.clone();
1209            let mut explicit_intent: Option<(
1210                crate::core::intent_protocol::IntentRecord,
1211                Option<String>,
1212                String,
1213            )> = None;
1214
1215            let pending_session_save = {
1216                let empty_args = serde_json::Map::new();
1217                let args_map = args.unwrap_or(&empty_args);
1218                let mut session = self.session.write().await;
1219                session.record_tool_receipt(
1220                    name,
1221                    action.as_deref(),
1222                    &input_md5,
1223                    &output_md5,
1224                    agent_id.as_deref(),
1225                    Some(&client_name),
1226                );
1227
1228                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
1229                    name,
1230                    action.as_deref(),
1231                    args_map,
1232                    session.project_root.as_deref(),
1233                ) {
1234                    let is_explicit =
1235                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
1236                    let root = session.project_root.clone();
1237                    let sid = session.id.clone();
1238                    session.record_intent(intent.clone());
1239                    if is_explicit {
1240                        explicit_intent = Some((intent, root, sid));
1241                    }
1242                }
1243                if session.should_save() {
1244                    session.prepare_save().ok()
1245                } else {
1246                    None
1247                }
1248            };
1249
1250            if let Some(prepared) = pending_session_save {
1251                let ir_clone = self.context_ir.clone();
1252                tokio::task::spawn_blocking(move || {
1253                    let _ = prepared.write_to_disk();
1254                    if let Some(ir) = ir_clone {
1255                        if let Ok(ir_guard) = ir.try_read() {
1256                            ir_guard.save();
1257                        }
1258                    }
1259                });
1260            }
1261
1262            if let Some((intent, root, session_id)) = explicit_intent {
1263                let _ = crate::core::intent_protocol::apply_side_effects(
1264                    &intent,
1265                    root.as_deref(),
1266                    &session_id,
1267                );
1268            }
1269
1270            if self.autonomy.is_enabled() {
1271                let (calls, project_root) = {
1272                    let session = self.session.read().await;
1273                    (session.stats.total_tool_calls, session.project_root.clone())
1274                };
1275
1276                if let Some(root) = project_root {
1277                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
1278                        let root_clone = root.clone();
1279                        tokio::task::spawn_blocking(move || {
1280                            let _ = crate::core::consolidation_engine::consolidate_latest(
1281                                &root_clone,
1282                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
1283                            );
1284                        });
1285                    }
1286                }
1287            }
1288
1289            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
1290            let input_token_count = crate::core::tokens::count_tokens(&input) as u64;
1291            let output_token_count_u64 = output_token_count as u64;
1292            let name_owned = name.to_string();
1293            tokio::task::spawn_blocking(move || {
1294                let pricing = crate::core::gain::model_pricing::ModelPricing::load();
1295                let quote = pricing.quote_from_env_or_agent_type(&client_name);
1296                let cost_usd =
1297                    quote
1298                        .cost
1299                        .estimate_usd(input_token_count, output_token_count_u64, 0, 0);
1300                crate::core::budget_tracker::BudgetTracker::global().record_cost_usd(cost_usd);
1301
1302                let mut store = crate::core::a2a::cost_attribution::CostStore::load();
1303                store.record_tool_call(
1304                    &agent_key,
1305                    &client_name,
1306                    &name_owned,
1307                    input_token_count,
1308                    output_token_count_u64,
1309                    0,
1310                );
1311                if let Err(e) = store.save() {
1312                    tracing::warn!("lean-ctx: failed to persist cost attribution: {e}");
1313                }
1314            });
1315        }
1316
1317        // Context Bus: conflict detection for knowledge writes in shared mode.
1318        if self.session_mode == crate::tools::SessionMode::Shared
1319            && name == "ctx_knowledge"
1320            && action.as_deref() == Some("remember")
1321        {
1322            if let Some(ref rt) = self.context_os {
1323                let my_agent = self.agent_id.read().await.clone();
1324                let category = helpers::get_str(args, "category");
1325                let key = helpers::get_str(args, "key");
1326                if let (Some(ref cat), Some(ref k)) = (&category, &key) {
1327                    let recent = rt.bus.recent_by_kind(
1328                        &self.workspace_id,
1329                        &self.channel_id,
1330                        "knowledge_remembered",
1331                        20,
1332                    );
1333                    for ev in &recent {
1334                        let p = &ev.payload;
1335                        let ev_cat = p.get("category").and_then(|v| v.as_str());
1336                        let ev_key = p.get("key").and_then(|v| v.as_str());
1337                        let ev_actor = ev.actor.as_deref();
1338                        if ev_cat == Some(cat.as_str())
1339                            && ev_key == Some(k.as_str())
1340                            && ev_actor != my_agent.as_deref()
1341                        {
1342                            let other = ev_actor.unwrap_or("unknown");
1343                            result_text = format!(
1344                                "[CONFLICT] Agent '{other}' recently wrote to the same knowledge key \
1345                                 '{cat}/{k}'. Review before proceeding.\n\n{result_text}"
1346                            );
1347                            break;
1348                        }
1349                    }
1350                }
1351            }
1352        }
1353
1354        // Context OS: persist shared session + publish events.
1355        if self.session_mode == crate::tools::SessionMode::Shared {
1356            let ws = self.workspace_id.clone();
1357            let ch = self.channel_id.clone();
1358            let rt = self.context_os.clone();
1359            let agent = self.agent_id.read().await.clone();
1360            let tool = name.to_string();
1361            let tool_action = action.clone();
1362            let tool_path = helpers::get_str(args, "path");
1363            let tool_category = helpers::get_str(args, "category");
1364            let tool_key = helpers::get_str(args, "key");
1365            let session_snapshot = self.session.read().await.clone();
1366            let session_task = session_snapshot.task.clone();
1367            tokio::task::spawn_blocking(move || {
1368                let Some(rt) = rt else {
1369                    return;
1370                };
1371                let Some(root) = session_snapshot.project_root.as_deref() else {
1372                    return;
1373                };
1374                rt.shared_sessions
1375                    .persist_best_effort(root, &ws, &ch, &session_snapshot);
1376                rt.metrics.record_session_persisted();
1377
1378                let mut base_payload = serde_json::json!({
1379                    "tool": tool,
1380                    "action": tool_action,
1381                });
1382                if let Some(ref p) = tool_path {
1383                    base_payload["path"] = serde_json::Value::String(p.clone());
1384                }
1385                if let Some(ref c) = tool_category {
1386                    base_payload["category"] = serde_json::Value::String(c.clone());
1387                }
1388                if let Some(ref k) = tool_key {
1389                    base_payload["key"] = serde_json::Value::String(k.clone());
1390                }
1391                if let Some(ref t) = session_task {
1392                    base_payload["reasoning"] = serde_json::Value::String(t.description.clone());
1393                }
1394
1395                if rt
1396                    .bus
1397                    .append(
1398                        &ws,
1399                        &ch,
1400                        &crate::core::context_os::ContextEventKindV1::ToolCallRecorded,
1401                        agent.as_deref(),
1402                        base_payload.clone(),
1403                    )
1404                    .is_some()
1405                {
1406                    rt.metrics.record_event_appended();
1407                    rt.metrics.record_event_broadcast();
1408                }
1409
1410                if let Some(secondary) =
1411                    crate::core::context_os::secondary_event_kind(&tool, tool_action.as_deref())
1412                {
1413                    if rt
1414                        .bus
1415                        .append(&ws, &ch, &secondary, agent.as_deref(), base_payload)
1416                        .is_some()
1417                    {
1418                        rt.metrics.record_event_appended();
1419                        rt.metrics.record_event_broadcast();
1420                    }
1421                }
1422            });
1423        }
1424
1425        let skip_checkpoint = minimal
1426            || matches!(
1427                name,
1428                "ctx_compress"
1429                    | "ctx_metrics"
1430                    | "ctx_benchmark"
1431                    | "ctx_analyze"
1432                    | "ctx_cache"
1433                    | "ctx_discover"
1434                    | "ctx_dedup"
1435                    | "ctx_session"
1436                    | "ctx_knowledge"
1437                    | "ctx_agent"
1438                    | "ctx_share"
1439                    | "ctx_gain"
1440                    | "ctx_overview"
1441                    | "ctx_preload"
1442                    | "ctx_cost"
1443                    | "ctx_heatmap"
1444                    | "ctx_task"
1445                    | "ctx_impact"
1446                    | "ctx_architecture"
1447                    | "ctx_smells"
1448                    | "ctx_workflow"
1449            );
1450
1451        if !skip_checkpoint && self.increment_and_check() {
1452            if let Some(checkpoint) = self.auto_checkpoint().await {
1453                let interval = LeanCtxServer::checkpoint_interval_effective();
1454                let hints = crate::core::profiles::active_profile().output_hints;
1455                if hints.checkpoint_in_output() && crate::core::protocol::meta_visible() {
1456                    let combined = format!(
1457                        "{result_text}\n\n--- AUTO CHECKPOINT (every {interval} calls) ---\n{checkpoint}"
1458                    );
1459                    return Ok(CallToolResult::success(vec![Content::text(combined)]));
1460                }
1461            }
1462        }
1463
1464        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1465        if tool_duration_ms > 100 {
1466            LeanCtxServer::append_tool_call_log(
1467                name,
1468                tool_duration_ms,
1469                0,
1470                0,
1471                None,
1472                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1473            );
1474        }
1475
1476        let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1477        if current_count > 0 && current_count.is_multiple_of(100) {
1478            std::thread::spawn(crate::cloud_sync::cloud_background_tasks);
1479        }
1480
1481        Ok(CallToolResult::success(vec![Content::text(result_text)]))
1482    }
1483
1484    /// Resolve project root from MCP client roots (once per session).
1485    /// Called on the first tool call. If the client supports `roots/list`,
1486    /// we query it and pick the best root with project markers.
1487    async fn resolve_roots_once(&self) {
1488        use std::sync::atomic::Ordering;
1489        if !self.has_client_roots.load(Ordering::Relaxed) {
1490            return;
1491        }
1492        if self.roots_resolved.swap(true, Ordering::Relaxed) {
1493            return;
1494        }
1495        let peer_guard = self.peer.read().await;
1496        let Some(peer) = peer_guard.as_ref() else {
1497            return;
1498        };
1499        let list_result = match peer.list_roots().await {
1500            Ok(r) => r,
1501            Err(e) => {
1502                tracing::warn!("roots/list failed: {e}");
1503                return;
1504            }
1505        };
1506        drop(peer_guard);
1507
1508        let uris: Vec<String> = list_result.roots.iter().map(|r| r.uri.clone()).collect();
1509        let validated_paths = roots::valid_dir_paths_from_uris(&uris);
1510        let Some(new_root) = roots::best_root_from_uris(&uris) else {
1511            return;
1512        };
1513
1514        let mut session = self.session.write().await;
1515        let old_root = session.project_root.clone();
1516
1517        let other_roots: Vec<String> = validated_paths
1518            .iter()
1519            .filter(|p| p.as_str() != new_root)
1520            .cloned()
1521            .collect();
1522        if !other_roots.is_empty() {
1523            session.extra_roots = other_roots;
1524            tracing::info!(
1525                "MCP roots: {} extra root(s) registered",
1526                session.extra_roots.len()
1527            );
1528        }
1529
1530        if old_root.as_deref() == Some(&new_root) {
1531            let _ = session.save();
1532            return;
1533        }
1534        tracing::info!(
1535            "MCP roots: switching project root from {:?} to {new_root}",
1536            old_root
1537        );
1538        if let Some(existing) =
1539            crate::core::session::SessionState::load_latest_for_project_root(&new_root)
1540        {
1541            *session = existing;
1542            session.extra_roots = validated_paths
1543                .iter()
1544                .filter(|p| p.as_str() != new_root)
1545                .cloned()
1546                .collect();
1547        }
1548        session.project_root = Some(new_root);
1549        let _ = session.save();
1550    }
1551}
1552
1553pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1554    crate::instructions::build_instructions_for_test(crp_mode)
1555}
1556
1557pub fn build_claude_code_instructions_for_test() -> String {
1558    crate::instructions::claude_code_instructions()
1559}
1560
1561fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
1562    if let Some(home) = dirs::home_dir() {
1563        if dir == home {
1564            return true;
1565        }
1566    }
1567    let dir_str = dir.to_string_lossy();
1568    dir_str.ends_with("/.claude")
1569        || dir_str.ends_with("/.codex")
1570        || dir_str.contains("/.claude/")
1571        || dir_str.contains("/.codex/")
1572}
1573
1574fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
1575    std::process::Command::new("git")
1576        .args(["rev-parse", "--show-toplevel"])
1577        .current_dir(dir)
1578        .stdout(std::process::Stdio::piped())
1579        .stderr(std::process::Stdio::null())
1580        .output()
1581        .ok()
1582        .and_then(|o| {
1583            if o.status.success() {
1584                String::from_utf8(o.stdout)
1585                    .ok()
1586                    .map(|s| s.trim().to_string())
1587            } else {
1588                None
1589            }
1590        })
1591}
1592
1593pub fn derive_project_root_from_cwd() -> Option<String> {
1594    let cwd = std::env::current_dir().ok()?;
1595    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
1596
1597    if is_home_or_agent_dir(&canonical) {
1598        return git_toplevel_from(&canonical);
1599    }
1600
1601    if has_project_marker(&canonical) {
1602        return Some(canonical.to_string_lossy().to_string());
1603    }
1604
1605    if let Some(git_root) = git_toplevel_from(&canonical) {
1606        return Some(git_root);
1607    }
1608
1609    if let Some(root) = detect_multi_root_workspace(&canonical) {
1610        return Some(root);
1611    }
1612
1613    // Fallback: use CWD as project root if it's a specific, safe directory.
1614    // This ensures bare directories (no .git, no markers) still work.
1615    // Guard: reject home dir, filesystem root, and agent sandbox dirs.
1616    if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
1617        tracing::info!(
1618            "No project markers found — using CWD as project root: {}",
1619            canonical.display()
1620        );
1621        return Some(canonical.to_string_lossy().to_string());
1622    }
1623
1624    None
1625}
1626
1627// Delegated to crate::core::pathutil::is_broad_or_unsafe_root
1628#[cfg(test)]
1629use crate::core::pathutil::is_broad_or_unsafe_root;
1630
1631/// Detect a multi-root workspace: a directory that has no project markers
1632/// itself, but contains child directories that do. In this case, use the
1633/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
1634fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
1635    let entries = std::fs::read_dir(dir).ok()?;
1636    let mut child_projects: Vec<String> = Vec::new();
1637
1638    for entry in entries.flatten() {
1639        let path = entry.path();
1640        if path.is_dir() && has_project_marker(&path) {
1641            let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
1642            child_projects.push(canonical.to_string_lossy().to_string());
1643        }
1644    }
1645
1646    if child_projects.len() >= 2 {
1647        let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
1648        let sep = if cfg!(windows) { ";" } else { ":" };
1649        let merged = if existing.is_empty() {
1650            child_projects.join(sep)
1651        } else {
1652            format!("{existing}{sep}{}", child_projects.join(sep))
1653        };
1654        std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
1655        tracing::info!(
1656            "Multi-root workspace detected at {}: auto-allowing {} child projects",
1657            dir.display(),
1658            child_projects.len()
1659        );
1660        return Some(dir.to_string_lossy().to_string());
1661    }
1662
1663    None
1664}
1665
1666pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1667    crate::tool_defs::list_all_tool_defs()
1668        .into_iter()
1669        .map(|(name, desc, _)| (name, desc))
1670        .collect()
1671}
1672
1673pub fn tool_schemas_json_for_test() -> String {
1674    crate::tool_defs::list_all_tool_defs()
1675        .iter()
1676        .map(|(name, _, schema)| format!("{name}: {schema}"))
1677        .collect::<Vec<_>>()
1678        .join("\n")
1679}
1680
1681/// Tools that always pass through the workflow gate regardless of state.
1682/// Read-only tools should never be blocked — agents need them for context
1683/// recovery after crashes or session transitions.
1684pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
1685    "ctx",
1686    "ctx_workflow",
1687    "ctx_read",
1688    "ctx_multi_read",
1689    "ctx_smart_read",
1690    "ctx_search",
1691    "ctx_tree",
1692    "ctx_session",
1693    "ctx_ledger",
1694];
1695
1696/// A workflow is stale if it hasn't been updated in 30 minutes.
1697/// This prevents dead workflows from blocking tools across sessions.
1698pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
1699    let elapsed = chrono::Utc::now()
1700        .signed_duration_since(run.updated_at)
1701        .num_minutes();
1702    elapsed > 30
1703}
1704
1705fn is_shell_tool_name(name: &str) -> bool {
1706    matches!(name, "ctx_shell" | "ctx_execute")
1707}
1708
1709fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
1710    let trimmed = cmd.trim();
1711    let parts: Vec<&str> = trimmed.split_whitespace().collect();
1712    if parts.len() < 2 {
1713        return None;
1714    }
1715    let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
1716    match bin {
1717        "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
1718            let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
1719            Some(file_arg.to_string())
1720        }
1721        _ => None,
1722    }
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727    use super::*;
1728
1729    #[test]
1730    fn project_markers_detected() {
1731        let tmp = tempfile::tempdir().unwrap();
1732        let root = tmp.path().join("myproject");
1733        std::fs::create_dir_all(&root).unwrap();
1734        assert!(!has_project_marker(&root));
1735
1736        std::fs::create_dir(root.join(".git")).unwrap();
1737        assert!(has_project_marker(&root));
1738    }
1739
1740    #[test]
1741    fn home_dir_detected_as_agent_dir() {
1742        if let Some(home) = dirs::home_dir() {
1743            assert!(is_home_or_agent_dir(&home));
1744        }
1745    }
1746
1747    #[test]
1748    fn agent_dirs_detected() {
1749        let claude = std::path::PathBuf::from("/home/user/.claude");
1750        assert!(is_home_or_agent_dir(&claude));
1751        let codex = std::path::PathBuf::from("/home/user/.codex");
1752        assert!(is_home_or_agent_dir(&codex));
1753        let project = std::path::PathBuf::from("/home/user/projects/myapp");
1754        assert!(!is_home_or_agent_dir(&project));
1755    }
1756
1757    #[test]
1758    fn test_unified_tool_count() {
1759        let tools = crate::tool_defs::unified_tool_defs();
1760        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1761    }
1762
1763    #[test]
1764    fn test_granular_tool_count() {
1765        let tools = crate::tool_defs::granular_tool_defs();
1766        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
1767    }
1768
1769    #[test]
1770    fn test_registry_tool_count_ssot() {
1771        let registry = crate::server::registry::build_registry();
1772        assert_eq!(
1773            registry.len(),
1774            63,
1775            "Registry tool count drift! Update this test AND all docs when adding/removing tools."
1776        );
1777    }
1778
1779    #[test]
1780    fn disabled_tools_filters_list() {
1781        let all = crate::tool_defs::granular_tool_defs();
1782        let total = all.len();
1783        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
1784        let filtered: Vec<_> = all
1785            .into_iter()
1786            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1787            .collect();
1788        assert_eq!(filtered.len(), total - 2);
1789        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
1790        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
1791    }
1792
1793    #[test]
1794    fn empty_disabled_tools_returns_all() {
1795        let all = crate::tool_defs::granular_tool_defs();
1796        let total = all.len();
1797        let disabled: Vec<String> = vec![];
1798        let filtered: Vec<_> = all
1799            .into_iter()
1800            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1801            .collect();
1802        assert_eq!(filtered.len(), total);
1803    }
1804
1805    #[test]
1806    fn misspelled_disabled_tool_is_silently_ignored() {
1807        let all = crate::tool_defs::granular_tool_defs();
1808        let total = all.len();
1809        let disabled = ["ctx_nonexistent_tool".to_string()];
1810        let filtered: Vec<_> = all
1811            .into_iter()
1812            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
1813            .collect();
1814        assert_eq!(filtered.len(), total);
1815    }
1816
1817    #[test]
1818    fn detect_multi_root_workspace_with_child_projects() {
1819        let tmp = tempfile::tempdir().unwrap();
1820        let workspace = tmp.path().join("workspace");
1821        std::fs::create_dir_all(&workspace).unwrap();
1822
1823        let proj_a = workspace.join("project-a");
1824        let proj_b = workspace.join("project-b");
1825        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1826        std::fs::create_dir_all(&proj_b).unwrap();
1827        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
1828
1829        let result = detect_multi_root_workspace(&workspace);
1830        assert!(
1831            result.is_some(),
1832            "should detect workspace with 2 child projects"
1833        );
1834
1835        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
1836    }
1837
1838    #[test]
1839    fn detect_multi_root_workspace_returns_none_for_single_project() {
1840        let tmp = tempfile::tempdir().unwrap();
1841        let workspace = tmp.path().join("workspace");
1842        std::fs::create_dir_all(&workspace).unwrap();
1843
1844        let proj_a = workspace.join("project-a");
1845        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
1846
1847        let result = detect_multi_root_workspace(&workspace);
1848        assert!(
1849            result.is_none(),
1850            "should not detect workspace with only 1 child project"
1851        );
1852    }
1853
1854    #[test]
1855    fn is_broad_or_unsafe_root_rejects_home() {
1856        if let Some(home) = dirs::home_dir() {
1857            assert!(is_broad_or_unsafe_root(&home));
1858        }
1859    }
1860
1861    #[test]
1862    fn is_broad_or_unsafe_root_rejects_filesystem_root() {
1863        assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
1864    }
1865
1866    #[test]
1867    fn is_broad_or_unsafe_root_rejects_agent_dirs() {
1868        assert!(is_broad_or_unsafe_root(std::path::Path::new(
1869            "/home/user/.claude"
1870        )));
1871        assert!(is_broad_or_unsafe_root(std::path::Path::new(
1872            "/home/user/.codex"
1873        )));
1874    }
1875
1876    #[test]
1877    fn is_broad_or_unsafe_root_allows_project_subdir() {
1878        let tmp = tempfile::tempdir().unwrap();
1879        let subdir = tmp.path().join("my-project");
1880        std::fs::create_dir_all(&subdir).unwrap();
1881        assert!(!is_broad_or_unsafe_root(&subdir));
1882    }
1883
1884    #[test]
1885    fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
1886        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1887            "/tmp/leanctx-test"
1888        )));
1889        assert!(!is_broad_or_unsafe_root(std::path::Path::new(
1890            "/tmp/my-project"
1891        )));
1892    }
1893
1894    #[test]
1895    fn is_broad_or_unsafe_root_allows_home_subdirs() {
1896        if let Some(home) = dirs::home_dir() {
1897            let subdir = home.join("projects").join("my-app");
1898            assert!(!is_broad_or_unsafe_root(&subdir));
1899        }
1900    }
1901
1902    #[test]
1903    fn derive_project_root_falls_back_to_bare_cwd() {
1904        let tmp = tempfile::tempdir().unwrap();
1905        let bare = tmp.path().join("bare-dir");
1906        std::fs::create_dir_all(&bare).unwrap();
1907
1908        let original = std::env::current_dir().unwrap();
1909        std::env::set_current_dir(&bare).unwrap();
1910        let result = derive_project_root_from_cwd();
1911        std::env::set_current_dir(original).unwrap();
1912
1913        assert!(result.is_some(), "bare dir should produce a project root");
1914        let root = result.unwrap();
1915        assert!(
1916            root.contains("bare-dir"),
1917            "fallback should use the bare dir path"
1918        );
1919    }
1920}