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