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