Skip to main content

lean_ctx/core/session/
state.rs

1use chrono::Utc;
2
3use crate::core::intent_protocol::{IntentRecord, IntentSource};
4
5use super::paths::{extract_cd_target, generate_session_id};
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9const MAX_FINDINGS: usize = 20;
10const MAX_DECISIONS: usize = 10;
11const MAX_FILES: usize = 50;
12const MAX_EVIDENCE: usize = 500;
13pub(crate) const BATCH_SAVE_INTERVAL: u32 = 5;
14/// #717: max time an unsaved change may linger before it is flushed even
15/// below the batch threshold — the dashboard reads session JSONs from disk,
16/// so a slow trickle of tool calls must still become visible promptly.
17pub(crate) const SESSION_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
18
19/// `TaskInfo.intent` of a task `auto_infer_task` built from touched-file
20/// patterns. A label, never a stated task (#1590).
21pub(crate) const INFERRED_TASK_INTENT: &str = "inferred";
22/// `TaskInfo.intent` of a task `auto_infer_task` built from the working tree's
23/// uncommitted files. Describes state, never a stated task.
24pub(crate) const GIT_TASK_INTENT: &str = "git";
25
26/// Whether a task intent marks an `auto_infer_task` label (re-derivable, never
27/// a stated task). A plan title is a stated task and stays authoritative.
28pub fn is_auto_task_intent(intent: Option<&str>) -> bool {
29    matches!(intent, Some(INFERRED_TASK_INTENT | GIT_TASK_INTENT))
30}
31
32/// How many file names a git-derived task description lists before `+N more`.
33const GIT_TASK_MAX_FILES: usize = 3;
34
35/// Describe uncommitted files by name — `Uncommitted changes: entropy.rs,
36/// call_graph.rs (+2 more)` — instead of a `git diff --stat` summary whose only
37/// words were "files changed, insertions, deletions". `None` when clean.
38pub(super) fn describe_uncommitted_files<'a>(
39    paths: impl IntoIterator<Item = &'a str>,
40) -> Option<String> {
41    let names: Vec<&str> = paths
42        .into_iter()
43        .map(str::trim)
44        .filter(|p| !p.is_empty())
45        .map(|p| p.rsplit('/').next().unwrap_or(p))
46        .collect();
47    if names.is_empty() {
48        return None;
49    }
50    let listed = names[..names.len().min(GIT_TASK_MAX_FILES)].join(", ");
51    let more = names.len().saturating_sub(GIT_TASK_MAX_FILES);
52    Some(if more > 0 {
53        format!("Uncommitted changes: {listed} (+{more} more)")
54    } else {
55        format!("Uncommitted changes: {listed}")
56    })
57}
58
59/// Converts durable session signals into cross-session knowledge facts.
60pub fn extract_session_facts(session: &SessionState) -> Vec<crate::core::knowledge::KnowledgeFact> {
61    session
62        .derived_facts()
63        .into_iter()
64        .map(|(fact, _)| fact)
65        .collect()
66}
67
68impl SessionState {
69    /// [`extract_session_facts`], each fact with the finding it is built from
70    /// when it is built from exactly one: the `auto:blocker` facts. A store
71    /// that keeps such a fact carries the finding's source binding over to it
72    /// (`consolidation_engine::import_session_into`). A decision and a
73    /// touched-file record have no source binding to carry: `None`.
74    pub(crate) fn derived_facts(
75        &self,
76    ) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
77        derive_session_facts(self)
78    }
79}
80
81fn derive_session_facts(
82    session: &SessionState,
83) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
84    let mut facts = Vec::new();
85
86    facts.extend(session.decisions.iter().filter_map(|decision| {
87        let summary = decision.summary.trim();
88        if summary.is_empty() {
89            return None;
90        }
91        let value = decision
92            .rationale
93            .as_deref()
94            .filter(|rationale| !rationale.trim().is_empty())
95            .map_or_else(
96                || summary.to_owned(),
97                |rationale| format!("{summary} — {rationale}"),
98            );
99        Some((
100            auto_session_fact("auto:decision", value, session, decision.timestamp, 0.9),
101            None,
102        ))
103    }));
104
105    facts.extend(
106        session
107            .files_touched
108            .iter()
109            .filter(|file| file.modified)
110            .map(|file| {
111                let summary = file
112                    .summary
113                    .as_deref()
114                    .filter(|summary| !summary.trim().is_empty())
115                    .map_or_else(String::new, |summary| format!("; {summary}"));
116                let fact = auto_session_fact(
117                    "auto:pattern",
118                    format!(
119                        "Modified {} (mode: {}, reads: {}){summary}",
120                        file.path, file.last_mode, file.read_count
121                    ),
122                    session,
123                    session.updated_at,
124                    0.8,
125                );
126                (fact, None)
127            }),
128    );
129
130    facts.extend(session.findings.iter().filter_map(|finding| {
131        let summary = finding.summary.trim();
132        if summary.is_empty() || crate::core::auto_findings::is_activity_record(summary) {
133            return None;
134        }
135        let normalized = summary.to_ascii_lowercase();
136        let label = if ["resolved", "fixed", "solved", "unblocked", "passed"]
137            .iter()
138            .any(|keyword| normalized.contains(keyword))
139        {
140            "Resolution"
141        } else if [
142            "block", "fail", "error", "unable", "cannot", "missing", "timeout", "denied",
143        ]
144        .iter()
145        .any(|keyword| normalized.contains(keyword))
146        {
147            "Blocker"
148        } else {
149            "Finding"
150        };
151        let location = finding.file.as_deref().map_or_else(String::new, |file| {
152            finding
153                .line
154                .map_or_else(|| format!("{file}: "), |line| format!("{file}:{line}: "))
155        });
156        let fact = auto_session_fact(
157            "auto:blocker",
158            format!("{label}: {location}{summary}"),
159            session,
160            finding.timestamp,
161            0.75,
162        );
163        Some((fact, Some(finding)))
164    }));
165
166    facts
167}
168
169fn auto_session_fact(
170    category: &str,
171    value: String,
172    session: &SessionState,
173    timestamp: chrono::DateTime<Utc>,
174    confidence: f32,
175) -> crate::core::knowledge::KnowledgeFact {
176    let key = value.clone();
177    crate::core::knowledge::KnowledgeFact {
178        anchors: Vec::new(),
179        review: None,
180        capture: Default::default(),
181        origin: crate::core::knowledge::FactOrigin::Unverified,
182        category: category.to_owned(),
183        key,
184        value,
185        source_session: session.id.clone(),
186        confidence,
187        created_at: timestamp,
188        last_confirmed: timestamp,
189        retrieval_count: 0,
190        last_retrieved: None,
191        valid_from: None,
192        valid_until: None,
193        supersedes: None,
194        confirmation_count: 1,
195        feedback_up: 0,
196        feedback_down: 0,
197        last_feedback: None,
198        privacy: Default::default(),
199        sensitivity: Default::default(),
200        imported_from: None,
201        archetype: Default::default(),
202        fidelity: None,
203        revision_count: 0,
204        status: None,
205        validity: None,
206    }
207}
208
209impl Default for SessionState {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl SessionState {
216    /// Creates a new session with a unique ID and current timestamp.
217    pub fn new() -> Self {
218        let now = Utc::now();
219        Self {
220            canonical_checkpoint: None,
221            save_gate: Default::default(),
222            last_save_failed: false,
223            id: generate_session_id(),
224            version: 0,
225            started_at: now,
226            updated_at: now,
227            project_root: None,
228            shell_cwd: None,
229            task: None,
230            findings: Vec::new(),
231            decisions: Vec::new(),
232            handoff_context: Vec::new(),
233            files_touched: Vec::new(),
234            test_results: None,
235            progress: Vec::new(),
236            next_steps: Vec::new(),
237            evidence: Vec::new(),
238            intents: Vec::new(),
239            active_structured_intent: None,
240            stats: SessionStats::default(),
241            terse_mode: false,
242            compression_level: String::new(),
243            last_consolidate_ts: None,
244            last_aaak_hash: None,
245            extra_roots: Vec::new(),
246            live_zone: super::types::LiveZoneSessionState::default(),
247            playbook: super::playbook::Playbook::default(),
248            last_semantic_query: None,
249            last_flush: None,
250        }
251        .with_compression_from_config()
252    }
253
254    pub fn refresh_handoff_context(&mut self) {
255        let Some(project_root) = self.project_root.clone() else {
256            self.handoff_context.clear();
257            return;
258        };
259
260        let session_id = self.id.clone();
261        self.handoff_context = crate::core::knowledge::ProjectKnowledge::load(&project_root)
262            .map(|knowledge| {
263                // The handoff context is printed with its fact values: only
264                // facts that may be delivered now are copied into it
265                // (`ProjectKnowledge::delivery_filter`).
266                let deliverable = knowledge.delivery_filter();
267                knowledge
268                    .recent_decisions(10)
269                    .into_iter()
270                    .filter(|fact| fact.source_session != session_id && deliverable(fact))
271                    .collect()
272            })
273            .unwrap_or_default();
274    }
275
276    fn with_compression_from_config(mut self) -> Self {
277        let cfg = crate::core::config::Config::load();
278        let level = crate::core::config::CompressionLevel::effective(&cfg);
279        self.compression_level = level.label().to_string();
280        self.terse_mode = level.is_active();
281        self
282    }
283
284    /// Bumps the version counter and marks the session as dirty.
285    pub fn increment(&mut self) {
286        self.version += 1;
287        self.updated_at = Utc::now();
288        self.stats.unsaved_changes += 1;
289    }
290
291    /// Returns `true` if enough changes have accumulated to warrant a disk
292    /// save — or, since #717, if any change has waited longer than
293    /// `SESSION_FLUSH_INTERVAL`: the 5-change batch alone left slow
294    /// sessions invisible to the dashboard (stuck "idle") for the whole
295    /// batch window. A fresh in-memory session flushes its first change
296    /// immediately so new activity surfaces at once.
297    pub fn should_save(&self) -> bool {
298        if self.stats.unsaved_changes == 0 {
299            return false;
300        }
301        if self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL {
302            return true;
303        }
304        match self.last_flush {
305            Some(t) => t.elapsed() >= SESSION_FLUSH_INTERVAL,
306            None => true,
307        }
308    }
309
310    /// Sets the active task and infers a structured intent from the description.
311    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
312        let (description_clean, _) =
313            crate::core::secret_detection::scan_and_redact_from_config(description);
314        // A canonical title edit continues the admitted task, not a new task ID.
315        let progress_pct = if self.canonical_checkpoint.is_some() {
316            self.task.as_ref().and_then(|task| task.progress_pct)
317        } else {
318            None
319        };
320        self.task = Some(TaskInfo {
321            description: description_clean.clone(),
322            intent: intent.map(std::string::ToString::to_string),
323            progress_pct,
324        });
325        // An auto-inferred label is not a stated task: its words must not
326        // become the structured intent that steers entropy/IB reads.
327        if is_auto_task_intent(intent) {
328            self.increment();
329            return;
330        }
331
332        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
333        let si = if touched.is_empty() {
334            crate::core::intent_engine::StructuredIntent::from_query(&description_clean)
335        } else {
336            crate::core::intent_engine::StructuredIntent::from_query_with_session(
337                &description_clean,
338                &touched,
339            )
340        };
341        if si.confidence >= 0.7 {
342            self.active_structured_intent = Some(si);
343        }
344
345        self.increment();
346    }
347
348    /// Auto-infers the task from available context (plans, git diff, file patterns).
349    /// Never overwrites a stated task; an earlier *auto-inferred* one is
350    /// re-derived, so a label from a past session or a since-committed working
351    /// tree does not linger as "the task" forever.
352    pub fn auto_infer_task(&mut self) {
353        if self
354            .task
355            .as_ref()
356            .is_some_and(|t| !is_auto_task_intent(t.intent.as_deref()))
357        {
358            return;
359        }
360        let inferred = self.infer_task();
361        let unchanged = match (&self.task, &inferred) {
362            (Some(t), Some((desc, intent))) => {
363                t.description == *desc && t.intent.as_deref() == Some(*intent)
364            }
365            (None, None) => true,
366            _ => false,
367        };
368        if unchanged {
369            return;
370        }
371        if let Some((desc, intent)) = inferred {
372            self.set_task(&desc, Some(intent));
373        } else {
374            // Nothing to infer any more (plan done, tree committed): a stale
375            // auto label must not outlive the state it described.
376            self.task = None;
377            self.increment();
378        }
379    }
380
381    /// The first available auto source: active plan, uncommitted files,
382    /// touched-file patterns. `(description, intent)`.
383    fn infer_task(&self) -> Option<(String, &'static str)> {
384        if let Some(task_from_plan) = Self::infer_task_from_plans() {
385            return Some((task_from_plan, "plan"));
386        }
387        if let Some(ref root) = self.project_root
388            && let Some(task_from_git) = Self::infer_task_from_git(root)
389        {
390            return Some((task_from_git, GIT_TASK_INTENT));
391        }
392        if self.files_touched.len() < 3 {
393            return None;
394        }
395        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
396        let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
397        if intent.confidence < 0.5 {
398            return None;
399        }
400        let dirs: std::collections::BTreeSet<&str> = touched
401            .iter()
402            .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
403            .collect();
404        let primary_dir = dirs.iter().next().unwrap_or(&".");
405        Some((
406            format!("Working on {} ({})", primary_dir, intent.task_type.as_str()),
407            INFERRED_TASK_INTENT,
408        ))
409    }
410
411    fn infer_task_from_plans() -> Option<String> {
412        let plans_dir = std::path::Path::new(".cursor/plans");
413        if !plans_dir.exists() {
414            return None;
415        }
416
417        let mut newest: Option<(std::time::SystemTime, String)> = None;
418        if let Ok(entries) = std::fs::read_dir(plans_dir) {
419            for entry in entries.flatten() {
420                let path = entry.path();
421                if !path.to_string_lossy().ends_with(".plan.md") {
422                    continue;
423                }
424                let mtime = entry.metadata().ok()?.modified().ok()?;
425                let content = std::fs::read_to_string(&path).ok()?;
426
427                // Check if plan has active (pending/in_progress) todos
428                let has_active =
429                    content.contains("status: pending") || content.contains("status: in_progress");
430                if !has_active {
431                    continue;
432                }
433
434                // Extract plan name from frontmatter
435                let name = content
436                    .lines()
437                    .find(|l| l.starts_with("name:"))
438                    .map_or("Unknown Plan", |l| {
439                        l.trim_start_matches("name:").trim().trim_matches('"')
440                    });
441
442                let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
443                if better {
444                    newest = Some((mtime, name.to_string()));
445                }
446            }
447        }
448
449        newest.map(|(_, name)| name)
450    }
451
452    fn infer_task_from_git(project_root: &str) -> Option<String> {
453        let output = std::process::Command::new("git")
454            .args(["diff", "--name-only", "--no-color"])
455            .current_dir(project_root)
456            .output()
457            .ok()?;
458        if !output.status.success() {
459            return None;
460        }
461        describe_uncommitted_files(String::from_utf8_lossy(&output.stdout).lines())
462    }
463
464    /// Records a finding (discovery or observation) in the session log.
465    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
466        let (summary_clean, _) =
467            crate::core::secret_detection::scan_and_redact_from_config(summary);
468        self.findings.push(Finding {
469            file: file.map(std::string::ToString::to_string),
470            line,
471            summary: summary_clean,
472            timestamp: Utc::now(),
473        });
474        while self.findings.len() > MAX_FINDINGS {
475            self.findings.remove(0);
476        }
477        self.increment();
478    }
479
480    /// Records a design or implementation decision with optional rationale.
481    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
482        let (summary_clean, _) =
483            crate::core::secret_detection::scan_and_redact_from_config(summary);
484        let rationale_clean =
485            rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
486        self.decisions.push(Decision {
487            summary: summary_clean,
488            rationale: rationale_clean,
489            timestamp: Utc::now(),
490        });
491        while self.decisions.len() > MAX_DECISIONS {
492            self.decisions.remove(0);
493        }
494        self.increment();
495    }
496
497    /// Records a file read/access in the session, incrementing its read count.
498    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
499        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
500            existing.read_count += 1;
501            existing.last_mode = mode.to_string();
502            existing.tokens = tokens;
503            if let Some(r) = file_ref {
504                existing.file_ref = Some(r.to_string());
505            }
506        } else {
507            let item_id = crate::core::context_field::ContextItemId::from_file(path);
508            self.files_touched.push(FileTouched {
509                path: path.to_string(),
510                file_ref: file_ref.map(std::string::ToString::to_string),
511                read_count: 1,
512                modified: false,
513                last_mode: mode.to_string(),
514                tokens,
515                stale: false,
516                context_item_id: Some(item_id.to_string()),
517                summary: None,
518            });
519            while self.files_touched.len() > MAX_FILES {
520                self.files_touched.remove(0);
521            }
522        }
523        self.stats.files_read += 1;
524        self.increment();
525    }
526
527    /// Marks a previously touched file as modified (written to).
528    pub fn mark_modified(&mut self, path: &str) {
529        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
530            existing.modified = true;
531        }
532        self.increment();
533    }
534
535    /// Sets a one-line content summary for a touched file (max 80 chars).
536    pub fn set_file_summary(&mut self, path: &str, summary: &str) {
537        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
538            let truncated = if summary.len() > 80 {
539                format!("{}…", &summary[..79])
540            } else {
541                summary.to_string()
542            };
543            existing.summary = Some(truncated);
544        }
545    }
546
547    /// Increments the tool call counter and accumulates token savings.
548    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
549        self.stats.total_tool_calls += 1;
550        self.stats.total_tokens_saved += tokens_saved;
551        self.stats.total_tokens_input += tokens_input;
552        self.stats.day_mut(Utc::now()).tool_calls += 1;
553        if self.stats.client.is_none() {
554            let client = crate::core::client_capabilities::current().client_id;
555            if client != "unknown" {
556                self.stats.client = Some(client);
557            }
558        }
559    }
560
561    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
562    pub fn record_intent(&mut self, mut intent: IntentRecord) {
563        if intent.occurrences == 0 {
564            intent.occurrences = 1;
565        }
566
567        if let Some(last) = self.intents.last_mut()
568            && last.fingerprint() == intent.fingerprint()
569        {
570            last.occurrences = last.occurrences.saturating_add(intent.occurrences);
571            last.timestamp = intent.timestamp;
572            match intent.source {
573                IntentSource::Inferred => self.stats.intents_inferred += 1,
574                IntentSource::Explicit => self.stats.intents_explicit += 1,
575            }
576            self.increment();
577            return;
578        }
579
580        match intent.source {
581            IntentSource::Inferred => self.stats.intents_inferred += 1,
582            IntentSource::Explicit => self.stats.intents_explicit += 1,
583        }
584
585        self.intents.push(intent);
586        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
587            self.intents.remove(0);
588        }
589        self.increment();
590    }
591
592    /// Appends an auditable evidence record for a tool invocation.
593    pub fn record_tool_receipt(
594        &mut self,
595        tool: &str,
596        action: Option<&str>,
597        input_md5: &str,
598        output_md5: &str,
599        agent_id: Option<&str>,
600        client_name: Option<&str>,
601    ) {
602        self.record_tool_receipt_with_task(
603            tool,
604            action,
605            input_md5,
606            output_md5,
607            agent_id,
608            client_name,
609            None,
610        );
611    }
612
613    /// Records a tool receipt with optional MCP task lineage.
614    pub fn record_tool_receipt_with_task(
615        &mut self,
616        tool: &str,
617        action: Option<&str>,
618        input_md5: &str,
619        output_md5: &str,
620        agent_id: Option<&str>,
621        client_name: Option<&str>,
622        task_id: Option<&str>,
623    ) {
624        let now = Utc::now();
625        let mut push = |key: String| {
626            self.evidence.push(EvidenceRecord {
627                kind: EvidenceKind::ToolCall,
628                key,
629                value: None,
630                tool: Some(tool.to_string()),
631                input_md5: Some(input_md5.to_string()),
632                output_md5: Some(output_md5.to_string()),
633                agent_id: agent_id.map(std::string::ToString::to_string),
634                client_name: client_name.map(std::string::ToString::to_string),
635                task_id: task_id.map(std::string::ToString::to_string),
636                timestamp: now,
637            });
638        };
639
640        push(format!("tool:{tool}"));
641        if let Some(a) = action {
642            push(format!("tool:{tool}:{a}"));
643        }
644        while self.evidence.len() > MAX_EVIDENCE {
645            self.evidence.remove(0);
646        }
647        self.increment();
648    }
649
650    /// Appends a manual (non-tool) evidence record to the audit log.
651    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
652        self.evidence.push(EvidenceRecord {
653            kind: EvidenceKind::Manual,
654            key: key.to_string(),
655            value: value.map(std::string::ToString::to_string),
656            tool: None,
657            input_md5: None,
658            output_md5: None,
659            agent_id: None,
660            client_name: None,
661            task_id: None,
662            timestamp: Utc::now(),
663        });
664        while self.evidence.len() > MAX_EVIDENCE {
665            self.evidence.remove(0);
666        }
667        self.increment();
668    }
669
670    /// Returns `true` if an evidence record with the given key exists.
671    pub fn has_evidence_key(&self, key: &str) -> bool {
672        self.evidence.iter().any(|e| e.key == key)
673    }
674
675    /// Increments the session-level cache hit counter.
676    pub fn record_cache_hit(&mut self) {
677        self.stats.cache_hits += 1;
678        self.stats.day_mut(Utc::now()).cache_hits += 1;
679    }
680
681    /// Increments the session-level command counter.
682    pub fn record_command(&mut self) {
683        self.stats.commands_run += 1;
684    }
685
686    /// Returns the effective working directory for shell commands.
687    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
688    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
689    /// to prevent MCP clients from escaping the workspace.
690    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
691        self.effective_cwd_checked(explicit_cwd).0
692    }
693
694    /// Like [`Self::effective_cwd`], but also reports *why* an explicit `cwd`
695    /// request was rejected by the project-root jail and silently replaced with
696    /// the project root (#629).
697    ///
698    /// The jail itself is deliberate sandboxing (it stops MCP clients escaping
699    /// the workspace) and must stay — the only gap was that the substitution was
700    /// silent, so a caller running `pwd && ls` in what they think is dir A
701    /// actually ran in the project root with no indication why. Callers that
702    /// surface output to a human/agent (e.g. `ctx_shell`) use the returned
703    /// `Option<String>` reason to append a one-line hint instead of letting the
704    /// swap pass unnoticed; `effective_cwd` keeps the original lossless behaviour.
705    pub fn effective_cwd_checked(&self, explicit_cwd: Option<&str>) -> (String, Option<String>) {
706        let root = self.project_root.as_deref().unwrap_or(".");
707        if let Some(cwd) = explicit_cwd
708            && !cwd.is_empty()
709            && cwd != "."
710        {
711            return Self::jail_cwd(cwd, root);
712        }
713        if let Some(ref cwd) = self.shell_cwd {
714            return (cwd.clone(), None);
715        }
716        if let Some(ref r) = self.project_root {
717            return (r.clone(), None);
718        }
719        (
720            std::env::current_dir()
721                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string()),
722            None,
723        )
724    }
725
726    /// The directory a command is to RUN in, or the reason there is none.
727    ///
728    /// Unlike [`Self::effective_cwd_checked`] nothing is substituted: an
729    /// explicit `cwd` the project jail rejects is an error, so the command is
730    /// not executed, backgrounded or cached under a directory the caller did
731    /// not name. The stored shell cwd is held to the same jail — it was
732    /// accepted when it was stored, but the project root may have changed
733    /// since. Without a project root there is no jail to hold it to, and the
734    /// stored value is used as before.
735    pub fn execution_cwd(&self, explicit_cwd: Option<&str>) -> Result<String, String> {
736        let jailed = |candidate: &str, root: &str| {
737            crate::core::pathjail::jail_path(
738                std::path::Path::new(candidate),
739                std::path::Path::new(root),
740            )
741            .map(|path| path.to_string_lossy().to_string())
742            .map_err(|reason| reason.to_string())
743        };
744        if let Some(cwd) = explicit_cwd
745            && !cwd.is_empty()
746            && cwd != "."
747        {
748            return jailed(cwd, self.project_root.as_deref().unwrap_or("."));
749        }
750        match (self.shell_cwd.as_deref(), self.project_root.as_deref()) {
751            (Some(cwd), Some(root)) => jailed(cwd, root),
752            (Some(cwd), None) => Ok(cwd.to_string()),
753            (None, Some(root)) => Ok(root.to_string()),
754            (None, None) => Ok(std::env::current_dir()
755                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())),
756        }
757    }
758
759    /// Verifies that `candidate` is within the project jail.
760    ///
761    /// Falls back to `fallback_root` if the candidate escapes, returning the
762    /// jail-rejection reason as the second tuple element so callers can surface
763    /// it instead of silently substituting the root (#629). `None` means the
764    /// candidate was accepted as-is.
765    fn jail_cwd(candidate: &str, fallback_root: &str) -> (String, Option<String>) {
766        let p = std::path::Path::new(candidate);
767        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
768            Ok(jailed) => (jailed.to_string_lossy().to_string(), None),
769            Err(reason) => (fallback_root.to_string(), Some(reason.to_string())),
770        }
771    }
772
773    /// Persist an explicit, jail-accepted `cwd` argument as the live shell
774    /// cwd (#707). `update_shell_cwd` only tracks `cd` inside the command
775    /// text, but clients that switch checkouts mid-session (Claude Code
776    /// after `EnterWorktree`) pass the new directory as the `cwd` *param* of
777    /// every subsequent call — without persisting it, the worktree-divergence
778    /// detection in path resolution never sees the switch. Callers must pass
779    /// a cwd that already passed the project-root jail.
780    pub fn note_explicit_cwd(&mut self, cwd: &str) {
781        let path = std::path::Path::new(cwd);
782        if path.is_absolute() && path.is_dir() {
783            self.shell_cwd = Some(
784                crate::core::pathutil::safe_canonicalize_or_self(path)
785                    .to_string_lossy()
786                    .to_string(),
787            );
788        }
789    }
790
791    /// Updates shell_cwd by detecting `cd` in the command.
792    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
793    /// `cd ..`, and chained commands like `cd foo && ...`.
794    /// The new CWD is jail-checked against the project root.
795    pub fn update_shell_cwd(&mut self, command: &str) {
796        let base = self.effective_cwd(None);
797        if let Some(new_cwd) = extract_cd_target(command, &base) {
798            let path = std::path::Path::new(&new_cwd);
799            if path.exists() && path.is_dir() {
800                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
801                    .to_string_lossy()
802                    .to_string();
803                let root = self.project_root.as_deref().unwrap_or(".");
804                if crate::core::pathjail::jail_path(
805                    std::path::Path::new(&canonical),
806                    std::path::Path::new(root),
807                )
808                .is_ok()
809                {
810                    self.shell_cwd = Some(canonical);
811                }
812            }
813        }
814    }
815}