lean_ctx/core/conversation.rs
1//! Conversation identity for read-cache scoping.
2//!
3//! The `[unchanged]` re-read stub means *"you already have this in context"* —
4//! which is only true within the **same conversation / context window**. The
5//! read [`SessionCache`](crate::core::cache::SessionCache) is shared across all
6//! chats served by one daemon, so without scoping a file delivered in chat A
7//! could be stubbed for a re-read in chat B (which never received it).
8//!
9//! Cursor's hooks write the live conversation id to `active_transcript.json`;
10//! hosts without one (Claude Code / Codex / CodeBuddy) supply a per-session
11//! `session_id` instead, which `load_active_transcript` returns as the scope id
12//! so a new session never inherits a prior one's stubs (#1004). Either way it
13//! carries a 2h TTL and we read it via
14//! [`crate::hook_handlers::load_active_transcript`] but
15//! cache it behind a short TTL so the read hot path never stats+parses a file on
16//! every call. The last-known-good value is retained across a transient refresh
17//! miss, so a momentary read failure never spuriously invalidates valid stubs.
18//!
19//! A Cursor subagent (`CURSOR_TASK_ID` set) is given its own `task:{id}` scope,
20//! so it is never served — nor records — a stub under another agent's identity;
21//! this lets the stub gate replace the old blanket subagent force-fresh (#956).
22//!
23//! ## Concurrency hardening (#1040)
24//!
25//! `active_transcript.json` is a single, last-writer-wins slot, and an MCP
26//! `ctx_read` call carries no caller identity (`ToolContext` has none), so with
27//! **two concurrent top-level chats** the daemon
28//! cannot prove which chat is asking: the resolved id may be the *other* chat's
29//! (last writer) or a TTL-stale value. A matching id is therefore untrustworthy
30//! while more than one conversation is live. Rather than risk serving chat B a
31//! stub for content only chat A received, the gate **withholds every stub while
32//! more than one conversation has been active recently** — correctness over the
33//! re-read savings. Single-conversation daemons (the common case) keep the full
34//! savings; sightings are sampled on each transcript refresh. Subagents never
35//! feed this signal (they short-circuit on their `task:` scope), so a parent +
36//! its subagents are not counted as concurrent.
37//!
38//! ### Zero detection lag, and the host limit (#1042)
39//!
40//! The stub decision resolves the caller with [`current_conversation_id_fresh`],
41//! which re-samples `active_transcript.json` (bypassing the `REFRESH_TTL`
42//! cache) and notes the writer *before* the gate runs. A freshly-appeared second
43//! chat is therefore detected with no lag, closing the small window in which a
44//! stub could still leak before the next refresh sampled it.
45//!
46//! What is *not* reachable as a pure lean-ctx change is recovering the stub
47//! savings while chats run concurrently: that needs a per-call caller identity,
48//! and Cursor exposes none. The MCP `tools/call` carries no conversation id (no
49//! documented `_meta`), and a `beforeMCPExecution` hook can gate a call's
50//! *permission* but not rewrite its *arguments*. So the daemon cannot prove
51//! which chat a direct `ctx_read` belongs to, and withholding under concurrency
52//! stays the correct ceiling until the host adds per-call identity.
53//!
54//! Disabled with `LEAN_CTX_CONVERSATION_SCOPE=0` (falls back to the legacy
55//! process-scoped behavior).
56
57use std::sync::OnceLock;
58use std::sync::RwLock;
59use std::time::{Duration, Instant};
60
61/// How long a resolved conversation id stays fresh before we re-read the file.
62const REFRESH_TTL: Duration = Duration::from_secs(3);
63
64/// How long a sighting of a conversation id keeps counting toward "concurrently
65/// active". Sized to comfortably span a multi-tab session's think/act gaps so a
66/// second chat that briefly goes quiet doesn't drop below the concurrency
67/// threshold and re-open the cross-chat stub hazard (#1040).
68const CONCURRENCY_WINDOW: Duration = Duration::from_secs(30);
69
70struct Cached {
71 value: Option<String>,
72 refreshed_at: Instant,
73}
74
75fn store() -> &'static RwLock<Option<Cached>> {
76 static STORE: OnceLock<RwLock<Option<Cached>>> = OnceLock::new();
77 STORE.get_or_init(|| RwLock::new(None))
78}
79
80/// Recent sightings of distinct conversation ids (`id` → last-seen instant),
81/// fed by [`refresh`]. Drives [`multiple_conversations_recent`] so the stub gate
82/// can tell when the daemon is multiplexing more than one chat (#1040).
83fn seen_store() -> &'static RwLock<Vec<(String, Instant)>> {
84 static SEEN: OnceLock<RwLock<Vec<(String, Instant)>>> = OnceLock::new();
85 SEEN.get_or_init(|| RwLock::new(Vec::new()))
86}
87
88pub(crate) fn scope_enabled() -> bool {
89 static ENABLED: OnceLock<bool> = OnceLock::new();
90 *ENABLED.get_or_init(|| {
91 !matches!(
92 std::env::var("LEAN_CTX_CONVERSATION_SCOPE")
93 .ok()
94 .as_deref()
95 .map(str::trim),
96 Some("0" | "false" | "off")
97 )
98 })
99}
100
101/// Pure core of the Cursor `task:` scope — split out so the derivation is
102/// unit-testable without touching the process environment.
103fn subagent_scope_from(task_id: Option<&str>) -> Option<String> {
104 task_id
105 .map(str::trim)
106 .filter(|id| !id.is_empty())
107 .map(|id| format!("task:{id}"))
108}
109
110/// Full scope resolver — pure core of [`subagent_scope`], testable without
111/// touching the process environment.
112///
113/// Priority:
114/// 1. `LEAN_CTX_SCOPE` — explicit operator/integration override
115/// 2. `CURSOR_TASK_ID` — Cursor's per-subagent identifier
116/// 3. `CLAUDECODE=1` — per-process scope for Claude Code (#1292). Claude Code
117/// does not (yet) provide a per-subagent env var, so parent and sub-agent
118/// share the same `session_id`. Each lean-ctx process gets a unique scope
119/// instead, which isolates their caches because each MCP connection is a
120/// separate stdio process.
121fn resolve_scope(
122 cursor_task_id: Option<&str>,
123 claudecode: Option<&str>,
124 explicit_scope: Option<&str>,
125 process_id: &str,
126) -> Option<String> {
127 if let Some(scope) = explicit_scope.map(str::trim).filter(|s| !s.is_empty()) {
128 return Some(format!("custom:{scope}"));
129 }
130 if let Some(scope) = subagent_scope_from(cursor_task_id) {
131 return Some(scope);
132 }
133 if claudecode.is_some_and(|v| v == "1") {
134 return Some(format!("proc:{process_id}"));
135 }
136 None
137}
138
139/// A unique identifier for this lean-ctx process, stable for its lifetime.
140/// Used as a scope discriminator when no explicit subagent ID is available
141/// (Claude Code, #1292).
142fn process_unique_id() -> &'static str {
143 static ID: OnceLock<String> = OnceLock::new();
144 ID.get_or_init(|| {
145 let pid = std::process::id();
146 let ts = std::time::SystemTime::now()
147 .duration_since(std::time::UNIX_EPOCH)
148 .map_or(0, |d| d.as_nanos());
149 format!("{pid}-{ts}")
150 })
151}
152
153/// A per-connection conversation scope that prevents cross-agent stub leakage.
154///
155/// Scope sources (first match wins):
156/// - `LEAN_CTX_SCOPE` — explicit override for custom integrations
157/// - `CURSOR_TASK_ID` — Cursor subagents (#952/#956)
158/// - `CLAUDECODE=1` — per-process scope for Claude Code (#1292); each
159/// lean-ctx stdio process gets a unique scope so a sub-agent's process
160/// never inherits the parent's stub deliveries
161///
162/// Returns `None` only when no agent environment is detected (standalone
163/// usage, plain Cursor without a subagent, etc.), preserving legacy
164/// transcript-based scoping.
165fn subagent_scope() -> Option<String> {
166 static SCOPE: OnceLock<Option<String>> = OnceLock::new();
167 SCOPE
168 .get_or_init(|| {
169 resolve_scope(
170 std::env::var("CURSOR_TASK_ID").ok().as_deref(),
171 std::env::var("CLAUDECODE").ok().as_deref(),
172 std::env::var("LEAN_CTX_SCOPE").ok().as_deref(),
173 process_unique_id(),
174 )
175 })
176 .clone()
177}
178
179/// The current conversation id, or `None` when no conversation context is
180/// available (hooks not installed, TTL expired with no prior value, or scoping
181/// disabled). `None` preserves the legacy process-scoped cache behavior.
182///
183/// Answers from the `REFRESH_TTL` cache when warm, so the read hot path never
184/// stats+parses a file on every call.
185pub fn current_conversation_id() -> Option<String> {
186 resolve_conversation_id(Freshness::Cached)
187}
188
189/// Like [`current_conversation_id`] but bypasses the `REFRESH_TTL` cache to
190/// re-sample `active_transcript.json` now. Used only on the re-read stub decision
191/// path: re-sampling notes a freshly-appeared second chat into the recency log
192/// (via `refresh`) *before* the gate runs, so concurrency is detected with no
193/// TTL lag and a stub can never leak to chat B in the window before detection
194/// catches up (#1042). The cost is one tiny, OS-cached transcript read per
195/// re-read — negligible against the source re-read it guards.
196pub fn current_conversation_id_fresh() -> Option<String> {
197 resolve_conversation_id(Freshness::Fresh)
198}
199
200/// Whether [`resolve_conversation_id`] may answer from the [`REFRESH_TTL`] cache
201/// ([`Freshness::Cached`]) or must re-sample the transcript ([`Freshness::Fresh`]).
202#[derive(Clone, Copy)]
203enum Freshness {
204 Cached,
205 Fresh,
206}
207
208/// Shared resolver behind [`current_conversation_id`] and its `_fresh` variant.
209/// The scope-off and subagent short-circuits are identical for both; only the
210/// `Cached` arm consults the TTL cache before falling through to [`refresh`].
211fn resolve_conversation_id(freshness: Freshness) -> Option<String> {
212 if !scope_enabled() {
213 return None;
214 }
215 // A subagent is its own scope (see `subagent_scope`) and that wins over the
216 // transcript id, so a subagent never inherits the parent's delivery identity
217 // — and never samples the transcript, so it can't feed the concurrency signal.
218 if let Some(scope) = subagent_scope() {
219 return Some(scope);
220 }
221 if let Ok(guard) = store().read()
222 && let Some(cached) = guard.as_ref()
223 && cache_is_usable(freshness, cached.refreshed_at.elapsed(), REFRESH_TTL)
224 {
225 return cached.value.clone();
226 }
227 refresh()
228}
229
230/// Pure core of the cache-vs-resample choice: a `Fresh` request always
231/// re-samples; a `Cached` one reuses an entry younger than `ttl`.
232fn cache_is_usable(freshness: Freshness, age: Duration, ttl: Duration) -> bool {
233 matches!(freshness, Freshness::Cached) && age < ttl
234}
235
236fn refresh() -> Option<String> {
237 let fresh = crate::hook_handlers::load_active_transcript().and_then(|(_, conv)| conv);
238 if let Some(id) = fresh.as_deref() {
239 note_conversation_seen(id);
240 }
241 if let Ok(mut guard) = store().write() {
242 // Retain last-known-good: a transient miss (file briefly absent or
243 // expired) must not flip a stable conversation to `None` and force
244 // needless cold re-reads.
245 if fresh.is_none()
246 && let Some(existing) = guard.as_ref()
247 && existing.value.is_some()
248 {
249 let kept = existing.value.clone();
250 *guard = Some(Cached {
251 value: kept.clone(),
252 refreshed_at: Instant::now(),
253 });
254 return kept;
255 }
256 *guard = Some(Cached {
257 value: fresh.clone(),
258 refreshed_at: Instant::now(),
259 });
260 }
261 fresh
262}
263
264/// Record a sighting of `id` in the recency log. Thin wrapper over the pure
265/// [`note_into`] so the global store stays an implementation detail.
266fn note_conversation_seen(id: &str) {
267 if let Ok(mut v) = seen_store().write() {
268 note_into(&mut v, id, Instant::now(), CONCURRENCY_WINDOW);
269 }
270}
271
272/// Pure core of [`note_conversation_seen`]: upsert `id`'s last-seen timestamp and
273/// drop sightings older than `window`. One entry per id, so the vec length is the
274/// number of distinct recent conversations.
275fn note_into(v: &mut Vec<(String, Instant)>, id: &str, now: Instant, window: Duration) {
276 v.retain(|(_, t)| now.duration_since(*t) < window);
277 if let Some(entry) = v.iter_mut().find(|(seen, _)| seen == id) {
278 entry.1 = now;
279 } else {
280 v.push((id.to_string(), now));
281 }
282}
283
284/// Pure core of [`multiple_conversations_recent`]: distinct ids seen within
285/// `window` of `now`.
286fn distinct_within(v: &[(String, Instant)], now: Instant, window: Duration) -> usize {
287 v.iter()
288 .filter(|(_, t)| now.duration_since(*t) < window)
289 .count()
290}
291
292/// True when more than one conversation has been active within
293/// [`CONCURRENCY_WINDOW`] — i.e. the daemon is multiplexing chats, so the shared
294/// `active_transcript.json` id can't be trusted to name the current caller and no
295/// stub is provably in-context (#1040).
296pub(crate) fn multiple_conversations_recent() -> bool {
297 seen_store()
298 .read()
299 .is_ok_and(|v| distinct_within(&v, Instant::now(), CONCURRENCY_WINDOW) > 1)
300}
301
302/// Whether a `[unchanged]` stub may be served for an entry that was delivered to
303/// `delivered`, given the `current` conversation.
304///
305/// `current == None` (no conversation context) preserves the legacy
306/// process-scoped behavior — stub allowed — **unless** more than one conversation
307/// has been active recently, in which case every stub is withheld because the
308/// shared id signal can't identify the caller (#1040).
309pub fn conversation_allows_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
310 allows_stub(
311 scope_enabled(),
312 multiple_conversations_recent(),
313 current,
314 delivered,
315 )
316}
317
318/// Pure decision core (no env / global reads) so the full matrix is unit-testable.
319fn allows_stub(
320 scope_on: bool,
321 concurrent: bool,
322 current: Option<&str>,
323 delivered: Option<&str>,
324) -> bool {
325 if !scope_on {
326 // Explicit legacy mode: one daemon == one conversation by contract.
327 return true;
328 }
329 if concurrent {
330 // Multiple chats live: a matching id can't be trusted to name THIS caller,
331 // so nothing is provably in-context — withhold every stub (#1040).
332 return false;
333 }
334 match (current, delivered) {
335 (Some(c), Some(d)) => c == d,
336 // Known caller, unknown delivery (pre-scoping entry) → can't prove → block.
337 (Some(_), None) => false,
338 // Unknown caller on a single-conversation daemon → legacy allow.
339 (None, _) => true,
340 }
341}
342
343/// Whether a *cold* `[unchanged]` stub may be served — i.e. one backed only by
344/// the persisted index ([`crate::core::read_stub_index`]) after a daemon
345/// restart, with no live in-memory entry.
346///
347/// Stricter than [`conversation_allows_stub`]: a cold stub crosses a process
348/// boundary, so we serve it **only** when both sides name the *same, known*
349/// conversation. Unlike the warm path there is no "no context → legacy" escape,
350/// because without a current conversation id we cannot prove the content is in
351/// the new process's context, and a wrong cold stub would resurrect exactly the
352/// cross-chat hazard #954 closed. Also withheld under concurrency (#1040).
353pub fn conversation_allows_cold_stub(current: Option<&str>, delivered: Option<&str>) -> bool {
354 allows_cold_stub(multiple_conversations_recent(), current, delivered)
355}
356
357/// Pure decision core of [`conversation_allows_cold_stub`].
358fn allows_cold_stub(concurrent: bool, current: Option<&str>, delivered: Option<&str>) -> bool {
359 !concurrent && matches!((current, delivered), (Some(c), Some(d)) if c == d)
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 // The gate matrix is tested through the pure cores (`allows_stub` /
367 // `allows_cold_stub`) so the assertions are deterministic regardless of the
368 // process-global recency store, which other tests mutate in parallel.
369
370 #[test]
371 fn no_current_context_allows_stub_legacy() {
372 // Single-conversation daemon, scoping on: behave exactly as before.
373 assert!(allows_stub(true, false, None, None));
374 assert!(allows_stub(true, false, None, Some("conv-a")));
375 }
376
377 #[test]
378 fn scope_disabled_always_allows_stub() {
379 // Explicit opt-out → legacy process scope, even under (irrelevant) concurrency.
380 assert!(allows_stub(false, true, Some("conv-b"), Some("conv-a")));
381 assert!(allows_stub(false, true, None, None));
382 }
383
384 #[test]
385 fn same_conversation_allows_stub() {
386 assert!(allows_stub(true, false, Some("conv-a"), Some("conv-a")));
387 }
388
389 #[test]
390 fn different_conversation_blocks_stub() {
391 assert!(!allows_stub(true, false, Some("conv-b"), Some("conv-a")));
392 }
393
394 #[test]
395 fn unknown_delivering_conversation_blocks_stub() {
396 // Entry delivered before scoping existed → cannot prove it is in context.
397 assert!(!allows_stub(true, false, Some("conv-a"), None));
398 }
399
400 #[test]
401 fn concurrency_withholds_every_warm_stub() {
402 // #1040: while >1 chat is live, even a same-id match is untrustworthy.
403 assert!(!allows_stub(true, true, Some("conv-a"), Some("conv-a")));
404 assert!(!allows_stub(true, true, None, None));
405 assert!(!allows_stub(true, true, None, Some("conv-a")));
406 }
407
408 #[test]
409 fn cold_stub_requires_both_known_and_matching() {
410 assert!(allows_cold_stub(false, Some("c"), Some("c")));
411 assert!(!allows_cold_stub(false, Some("c"), Some("d")));
412 // No "legacy" escape for the cold path: unknown either side → blocked.
413 assert!(!allows_cold_stub(false, None, Some("c")));
414 assert!(!allows_cold_stub(false, Some("c"), None));
415 assert!(!allows_cold_stub(false, None, None));
416 }
417
418 #[test]
419 fn cold_stub_withheld_under_concurrency() {
420 // #1040: a matching cold stub is still withheld while chats are multiplexed.
421 assert!(!allows_cold_stub(true, Some("c"), Some("c")));
422 }
423
424 #[test]
425 fn fresh_request_resamples_cached_request_honors_ttl() {
426 let ttl = Duration::from_secs(3);
427 // `Fresh` ignores the cache even for a brand-new entry → always re-samples,
428 // so the stub gate sees a just-appeared second chat with zero lag (#1042).
429 assert!(!cache_is_usable(
430 Freshness::Fresh,
431 Duration::from_millis(0),
432 ttl
433 ));
434 assert!(!cache_is_usable(Freshness::Fresh, ttl * 100, ttl));
435 // `Cached` reuses a within-TTL entry but re-samples once it has expired
436 // (age == ttl is already expired: the window is a strict `<`).
437 assert!(cache_is_usable(
438 Freshness::Cached,
439 Duration::from_secs(1),
440 ttl
441 ));
442 assert!(!cache_is_usable(Freshness::Cached, ttl, ttl));
443 assert!(!cache_is_usable(Freshness::Cached, ttl * 2, ttl));
444 }
445
446 #[test]
447 fn recency_counts_distinct_ids_and_dedupes_repeats() {
448 let now = Instant::now();
449 let win = Duration::from_secs(30);
450 let mut v = Vec::new();
451 note_into(&mut v, "a", now, win);
452 assert_eq!(distinct_within(&v, now, win), 1);
453 note_into(&mut v, "a", now, win); // same id refreshes, no new entry
454 assert_eq!(distinct_within(&v, now, win), 1);
455 note_into(&mut v, "b", now, win); // second chat → concurrency
456 assert_eq!(distinct_within(&v, now, win), 2);
457 }
458
459 #[test]
460 fn recency_prunes_sightings_older_than_window() {
461 let win = Duration::from_secs(30);
462 let t0 = Instant::now();
463 let mut v = Vec::new();
464 note_into(&mut v, "old", t0, win);
465 // A sighting well past the window prunes the stale one — back to one chat.
466 let t1 = t0 + win * 2;
467 note_into(&mut v, "new", t1, win);
468 assert_eq!(distinct_within(&v, t1, win), 1);
469 }
470
471 #[test]
472 fn subagent_scope_derives_a_distinct_non_none_id_from_task() {
473 assert_eq!(
474 subagent_scope_from(Some("abc123")),
475 Some("task:abc123".to_string())
476 );
477 assert_eq!(
478 subagent_scope_from(Some(" abc ")),
479 Some("task:abc".to_string())
480 );
481 assert_eq!(subagent_scope_from(Some("")), None);
482 assert_eq!(subagent_scope_from(Some(" ")), None);
483 assert_eq!(subagent_scope_from(None), None);
484 }
485
486 #[test]
487 fn subagent_scope_never_matches_a_plain_conversation() {
488 let sub = subagent_scope_from(Some("xyz")).unwrap();
489 assert!(!allows_stub(true, false, Some(&sub), Some("xyz")));
490 assert!(allows_stub(true, false, Some(&sub), Some(&sub)));
491 }
492
493 // --- resolve_scope (multi-host) tests (#1292) ---
494
495 #[test]
496 fn resolve_scope_explicit_override_wins() {
497 let s = resolve_scope(Some("task-1"), Some("1"), Some("my-scope"), "42-99");
498 assert_eq!(s, Some("custom:my-scope".to_string()));
499 }
500
501 #[test]
502 fn resolve_scope_cursor_task_id_beats_claude_code() {
503 let s = resolve_scope(Some("task-1"), Some("1"), None, "42-99");
504 assert_eq!(s, Some("task:task-1".to_string()));
505 }
506
507 #[test]
508 fn resolve_scope_claude_code_gets_process_scope() {
509 let s = resolve_scope(None, Some("1"), None, "100-111");
510 assert_eq!(s, Some("proc:100-111".to_string()));
511 }
512
513 #[test]
514 fn resolve_scope_no_agent_env_returns_none() {
515 assert_eq!(resolve_scope(None, None, None, "42-99"), None);
516 }
517
518 #[test]
519 fn resolve_scope_claude_code_different_processes_differ() {
520 let s1 = resolve_scope(None, Some("1"), None, "100-111").unwrap();
521 let s2 = resolve_scope(None, Some("1"), None, "200-222").unwrap();
522 assert_ne!(
523 s1, s2,
524 "different lean-ctx processes must get different scopes"
525 );
526 }
527
528 #[test]
529 fn claude_code_process_scope_never_matches_session_id() {
530 let scope = resolve_scope(None, Some("1"), None, "42-12345").unwrap();
531 assert!(!allows_stub(true, false, Some(&scope), Some("sess-abc")));
532 assert!(allows_stub(true, false, Some(&scope), Some(&scope)));
533 }
534
535 #[test]
536 fn claude_code_parent_and_subagent_stubs_isolated() {
537 let parent = resolve_scope(None, Some("1"), None, "100-111").unwrap();
538 let child = resolve_scope(None, Some("1"), None, "200-222").unwrap();
539 assert!(allows_stub(true, false, Some(&parent), Some(&parent)));
540 assert!(!allows_stub(true, false, Some(&child), Some(&parent)));
541 assert!(!allows_cold_stub(false, Some(&child), Some(&parent)));
542 }
543
544 #[test]
545 fn resolve_scope_blank_explicit_override_ignored() {
546 assert_eq!(resolve_scope(None, None, Some(" "), "42-99"), None);
547 assert_eq!(resolve_scope(None, None, Some(""), "42-99"), None);
548 }
549}