Skip to main content

lean_ctx/core/
agents.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::core::a2a::message::{A2AMessage, MessageCategory, MessagePriority, PrivacyLevel};
7
8const MAX_SCRATCHPAD_ENTRIES: usize = 200;
9const MAX_DIARY_ENTRIES: usize = 100;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AgentRegistry {
13    pub agents: Vec<AgentEntry>,
14    pub scratchpad: Vec<ScratchpadEntry>,
15    #[serde(default)]
16    pub logical_sessions: Vec<LogicalSessionPresence>,
17    #[serde(default)]
18    pub logical_session_telemetry_seen: bool,
19    pub updated_at: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct LogicalSessionPresence {
24    pub source: String,
25    pub workspace: String,
26    pub session_id: String,
27    pub opened_at: DateTime<Utc>,
28    pub last_heartbeat: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AgentDiary {
33    pub agent_id: String,
34    pub agent_type: String,
35    pub project_root: String,
36    pub entries: Vec<DiaryEntry>,
37    pub created_at: DateTime<Utc>,
38    pub updated_at: DateTime<Utc>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiaryEntry {
43    pub entry_type: DiaryEntryType,
44    pub content: String,
45    pub context: Option<String>,
46    pub timestamp: DateTime<Utc>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub enum DiaryEntryType {
51    Discovery,
52    Decision,
53    Blocker,
54    Progress,
55    Insight,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AgentEntry {
60    pub agent_id: String,
61    pub agent_type: String,
62    pub role: Option<String>,
63    pub project_root: String,
64    pub started_at: DateTime<Utc>,
65    pub last_active: DateTime<Utc>,
66    pub pid: u32,
67    pub status: AgentStatus,
68    pub status_message: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub enum AgentStatus {
73    Active,
74    Idle,
75    Finished,
76}
77
78impl std::fmt::Display for AgentStatus {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            AgentStatus::Active => write!(f, "active"),
82            AgentStatus::Idle => write!(f, "idle"),
83            AgentStatus::Finished => write!(f, "finished"),
84        }
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ScratchpadEntry {
90    pub id: String,
91    pub from_agent: String,
92    pub to_agent: Option<String>,
93    #[serde(default)]
94    pub task_id: Option<String>,
95    pub category: String,
96    #[serde(default)]
97    pub priority: MessagePriority,
98    #[serde(default)]
99    pub privacy: PrivacyLevel,
100    pub message: String,
101    #[serde(default)]
102    pub metadata: HashMap<String, String>,
103    #[serde(default)]
104    pub project_root: Option<String>,
105    pub timestamp: DateTime<Utc>,
106    pub read_by: Vec<String>,
107    #[serde(default)]
108    pub expires_at: Option<DateTime<Utc>>,
109}
110
111impl AgentRegistry {
112    pub fn new() -> Self {
113        Self {
114            agents: Vec::new(),
115            scratchpad: Vec::new(),
116            logical_sessions: Vec::new(),
117            logical_session_telemetry_seen: false,
118            updated_at: Utc::now(),
119        }
120    }
121
122    pub fn register(&mut self, agent_type: &str, role: Option<&str>, project_root: &str) -> String {
123        self.register_process(agent_type, role, project_root, std::process::id())
124    }
125
126    fn register_process(
127        &mut self,
128        agent_type: &str,
129        role: Option<&str>,
130        project_root: &str,
131        pid: u32,
132    ) -> String {
133        let agent_id = format!("{}-{}-{}", agent_type, pid, generate_short_id());
134
135        if let Some(existing) = self.agents.iter_mut().find(|a| a.pid == pid) {
136            existing.last_active = Utc::now();
137            existing.status = AgentStatus::Active;
138            existing.agent_type = agent_type.to_string();
139            existing.project_root = project_root.to_string();
140            if let Some(r) = role {
141                existing.role = Some(r.to_string());
142            }
143            return existing.agent_id.clone();
144        }
145
146        self.agents.push(AgentEntry {
147            agent_id: agent_id.clone(),
148            agent_type: agent_type.to_string(),
149            role: role.map(std::string::ToString::to_string),
150            project_root: project_root.to_string(),
151            started_at: Utc::now(),
152            last_active: Utc::now(),
153            pid,
154            status: AgentStatus::Active,
155            status_message: None,
156        });
157
158        self.updated_at = Utc::now();
159        crate::core::events::emit_agent_action(&agent_id, "register", None);
160        agent_id
161    }
162
163    /// Atomically registers this MCP process in the shared on-disk registry.
164    pub fn register_mcp_process(project_root: &str) -> Result<String, String> {
165        mutate_persistent(|registry| {
166            registry.cleanup_stale(24);
167            registry.register("mcp", Some("context-engine"), project_root)
168        })
169    }
170
171    /// Atomically refreshes a registered MCP process heartbeat.
172    pub fn heartbeat_persistent(agent_id: &str) -> Result<(), String> {
173        mutate_persistent(|registry| registry.update_heartbeat(agent_id))
174    }
175
176    /// Atomically marks a registered MCP process as finished.
177    pub fn finish_persistent(agent_id: &str) -> Result<(), String> {
178        mutate_persistent(|registry| {
179            registry.set_status(agent_id, AgentStatus::Finished, Some("connection closed"));
180        })
181    }
182
183    pub fn update_heartbeat(&mut self, agent_id: &str) {
184        if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
185            agent.last_active = Utc::now();
186        }
187    }
188
189    pub fn set_status(&mut self, agent_id: &str, status: AgentStatus, message: Option<&str>) {
190        if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
191            agent.status = status;
192            agent.status_message = message.map(std::string::ToString::to_string);
193            agent.last_active = Utc::now();
194        }
195        self.updated_at = Utc::now();
196    }
197    /// Records explicit logical-session presence supplied by an owning editor
198    /// integration. Tool activity is deliberately never treated as a session.
199    pub fn open_or_heartbeat_logical_session(
200        &mut self,
201        source: &str,
202        workspace: &str,
203        session_id: &str,
204    ) {
205        let now = Utc::now();
206        self.logical_session_telemetry_seen = true;
207        if let Some(session) = self.logical_sessions.iter_mut().find(|session| {
208            session.source == source
209                && session.workspace == workspace
210                && session.session_id == session_id
211        }) {
212            session.last_heartbeat = now;
213        } else {
214            self.logical_sessions.push(LogicalSessionPresence {
215                source: source.to_string(),
216                workspace: workspace.to_string(),
217                session_id: session_id.to_string(),
218                opened_at: now,
219                last_heartbeat: now,
220            });
221        }
222        self.updated_at = now;
223    }
224
225    pub fn close_logical_session(
226        &mut self,
227        source: &str,
228        workspace: &str,
229        session_id: &str,
230    ) -> bool {
231        self.logical_session_telemetry_seen = true;
232        let previous_len = self.logical_sessions.len();
233        self.logical_sessions.retain(|session| {
234            session.source != source
235                || session.workspace != workspace
236                || session.session_id != session_id
237        });
238        let removed = self.logical_sessions.len() != previous_len;
239        self.updated_at = Utc::now();
240        removed
241    }
242
243    pub fn cleanup_stale_logical_sessions(&mut self, max_age_seconds: u64) {
244        let seconds = i64::try_from(max_age_seconds).unwrap_or(i64::MAX);
245        let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
246        self.logical_sessions
247            .retain(|session| session.last_heartbeat >= cutoff);
248        self.updated_at = Utc::now();
249    }
250
251    pub fn list_active(&self, project_root: Option<&str>) -> Vec<&AgentEntry> {
252        self.agents
253            .iter()
254            .filter(|a| {
255                if let Some(root) = project_root {
256                    a.project_root == root && a.status != AgentStatus::Finished
257                } else {
258                    a.status != AgentStatus::Finished
259                }
260            })
261            .collect()
262    }
263
264    pub fn list_all(&self) -> &[AgentEntry] {
265        &self.agents
266    }
267
268    pub fn post_message(
269        &mut self,
270        from_agent: &str,
271        to_agent: Option<&str>,
272        category: &str,
273        message: &str,
274    ) -> String {
275        self.post_message_full(
276            from_agent,
277            to_agent,
278            category,
279            message,
280            PrivacyLevel::default(),
281            MessagePriority::default(),
282            None,
283        )
284    }
285
286    pub fn post_message_full(
287        &mut self,
288        from_agent: &str,
289        to_agent: Option<&str>,
290        category: &str,
291        message: &str,
292        privacy: PrivacyLevel,
293        priority: MessagePriority,
294        ttl_hours: Option<u64>,
295    ) -> String {
296        let id = generate_short_id();
297        let expires_at = ttl_hours.map(|h| Utc::now() + chrono::Duration::hours(h as i64));
298        self.scratchpad.push(ScratchpadEntry {
299            id: id.clone(),
300            from_agent: from_agent.to_string(),
301            to_agent: to_agent.map(std::string::ToString::to_string),
302            task_id: None,
303            category: category.to_string(),
304            priority,
305            privacy,
306            message: message.to_string(),
307            metadata: HashMap::new(),
308            project_root: None,
309            timestamp: Utc::now(),
310            read_by: vec![from_agent.to_string()],
311            expires_at,
312        });
313
314        if self.scratchpad.len() > MAX_SCRATCHPAD_ENTRIES {
315            self.scratchpad
316                .drain(0..self.scratchpad.len() - MAX_SCRATCHPAD_ENTRIES);
317        }
318
319        self.updated_at = Utc::now();
320        id
321    }
322
323    pub fn read_messages(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
324        let unread: Vec<usize> = self
325            .scratchpad
326            .iter()
327            .enumerate()
328            .filter(|(_, e)| {
329                !e.read_by.contains(&agent_id.to_string())
330                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
331            })
332            .map(|(i, _)| i)
333            .collect();
334
335        for i in &unread {
336            self.scratchpad[*i].read_by.push(agent_id.to_string());
337        }
338
339        self.scratchpad
340            .iter()
341            .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
342            .filter(|e| e.from_agent != agent_id)
343            .collect()
344    }
345
346    pub fn read_unread(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
347        let unread_indices: Vec<usize> = self
348            .scratchpad
349            .iter()
350            .enumerate()
351            .filter(|(_, e)| {
352                !e.read_by.contains(&agent_id.to_string())
353                    && e.from_agent != agent_id
354                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
355            })
356            .map(|(i, _)| i)
357            .collect();
358
359        for i in &unread_indices {
360            self.scratchpad[*i].read_by.push(agent_id.to_string());
361        }
362
363        self.updated_at = Utc::now();
364
365        self.scratchpad
366            .iter()
367            .filter(|e| {
368                e.from_agent != agent_id
369                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
370                    && e.read_by.contains(&agent_id.to_string())
371                    && e.read_by.iter().filter(|r| *r == agent_id).count() == 1
372            })
373            .collect()
374    }
375
376    pub fn cleanup_stale(&mut self, max_age_hours: u64) {
377        let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours as i64);
378
379        for agent in &mut self.agents {
380            if agent.status == AgentStatus::Finished {
381                continue;
382            }
383            if !is_process_alive(agent.pid) {
384                agent.status = AgentStatus::Finished;
385            }
386        }
387
388        // Remove finished agents older than the cutoff to keep recent history visible.
389        // Drop each retired agent's budget entry too — a finished/dead agent can't read
390        // again, so removing its budget loses no live enforcement and bounds BUDGETS.
391        self.agents.retain(|a| {
392            let retire = a.status == AgentStatus::Finished && a.last_active < cutoff;
393            if retire {
394                crate::core::agent_budget::remove(&a.agent_id);
395            }
396            !retire
397        });
398
399        self.updated_at = Utc::now();
400    }
401
402    pub fn save(&self) -> Result<(), String> {
403        let dir = agents_dir()?;
404        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
405
406        let lock_path = dir.join("registry.lock");
407        let _lock = FileLock::acquire(&lock_path)?;
408
409        self.save_locked(&dir)
410    }
411
412    fn save_locked(&self, dir: &std::path::Path) -> Result<(), String> {
413        let path = dir.join("registry.json");
414        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
415        std::fs::write(&path, json).map_err(|e| e.to_string())
416    }
417
418    pub fn load() -> Option<Self> {
419        let dir = agents_dir().ok()?;
420        let path = dir.join("registry.json");
421        let content = std::fs::read_to_string(&path).ok()?;
422        serde_json::from_str(&content).ok()
423    }
424
425    pub fn load_or_create() -> Self {
426        Self::load().unwrap_or_default()
427    }
428
429    /// Atomically load, mutate, and persist the registry under a single file
430    /// lock. `load_or_create()` + mutate + `save()` is a read-modify-write
431    /// race: `save()` only locks the final write, so two concurrent callers
432    /// (two MCP sessions registering, or the dashboard's own poll-triggered
433    /// `cleanup_stale` + save) can each load a stale snapshot and the last
434    /// writer silently drops the other's changes — e.g. a second session's
435    /// registration vanishing from the dashboard. Holding the lock across
436    /// the re-read closes that window: the read inside always sees the
437    /// latest on-disk state.
438    pub fn mutate_locked<T>(f: impl FnOnce(&mut Self) -> T) -> Result<(Self, T), String> {
439        let dir = agents_dir()?;
440        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
441
442        let lock_path = dir.join("registry.lock");
443        let _lock = FileLock::acquire(&lock_path)?;
444
445        let mut registry = Self::load().unwrap_or_default();
446        let out = f(&mut registry);
447        registry.save_locked(&dir)?;
448        Ok((registry, out))
449    }
450}
451
452impl Default for AgentRegistry {
453    fn default() -> Self {
454        Self::new()
455    }
456}
457
458impl AgentDiary {
459    pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
460        let now = Utc::now();
461        Self {
462            agent_id: agent_id.to_string(),
463            agent_type: agent_type.to_string(),
464            project_root: project_root.to_string(),
465            entries: Vec::new(),
466            created_at: now,
467            updated_at: now,
468        }
469    }
470
471    pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
472        self.entries.push(DiaryEntry {
473            entry_type,
474            content: content.to_string(),
475            context: context.map(std::string::ToString::to_string),
476            timestamp: Utc::now(),
477        });
478        if self.entries.len() > MAX_DIARY_ENTRIES {
479            self.entries
480                .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
481        }
482        self.updated_at = Utc::now();
483    }
484
485    pub fn format_summary(&self) -> String {
486        if self.entries.is_empty() {
487            return format!("Diary [{}]: empty", self.agent_id);
488        }
489        let mut out = format!(
490            "Diary [{}] ({} entries):\n",
491            self.agent_id,
492            self.entries.len()
493        );
494        let now = Utc::now();
495        for e in self.entries.iter().rev().take(10) {
496            let age = (now - e.timestamp).num_minutes();
497            let prefix = match e.entry_type {
498                DiaryEntryType::Discovery => "FOUND",
499                DiaryEntryType::Decision => "DECIDED",
500                DiaryEntryType::Blocker => "BLOCKED",
501                DiaryEntryType::Progress => "DONE",
502                DiaryEntryType::Insight => "INSIGHT",
503            };
504            let ctx = e
505                .context
506                .as_deref()
507                .map(|c| format!(" [{c}]"))
508                .unwrap_or_default();
509            out.push_str(&format!("  [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
510        }
511        out
512    }
513
514    pub fn format_compact(&self) -> String {
515        if self.entries.is_empty() {
516            return String::new();
517        }
518        let items: Vec<String> = self
519            .entries
520            .iter()
521            .rev()
522            .take(5)
523            .map(|e| {
524                let prefix = match e.entry_type {
525                    DiaryEntryType::Discovery => "F",
526                    DiaryEntryType::Decision => "D",
527                    DiaryEntryType::Blocker => "B",
528                    DiaryEntryType::Progress => "P",
529                    DiaryEntryType::Insight => "I",
530                };
531                format!("{prefix}:{}", truncate(&e.content, 50))
532            })
533            .collect();
534        format!("diary:{}|{}", self.agent_id, items.join("|"))
535    }
536
537    pub fn save(&self) -> Result<(), String> {
538        let dir = diary_dir()?;
539        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
540        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
541        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
542        std::fs::write(&path, json).map_err(|e| e.to_string())
543    }
544
545    pub fn load(agent_id: &str) -> Option<Self> {
546        let dir = diary_dir().ok()?;
547        let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
548        let content = std::fs::read_to_string(&path).ok()?;
549        serde_json::from_str(&content).ok()
550    }
551
552    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
553        Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
554    }
555
556    pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
557        let Ok(dir) = diary_dir() else {
558            return Vec::new();
559        };
560        if !dir.exists() {
561            return Vec::new();
562        }
563        let mut results = Vec::new();
564        if let Ok(entries) = std::fs::read_dir(&dir) {
565            for entry in entries.flatten() {
566                if entry.path().extension().and_then(|e| e.to_str()) == Some("json")
567                    && let Ok(content) = std::fs::read_to_string(entry.path())
568                    && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
569                {
570                    results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
571                }
572            }
573        }
574        results.sort_by_key(|x| std::cmp::Reverse(x.2));
575        results
576    }
577
578    /// Load every diary whose `project_root` matches `project_root`, most
579    /// recently updated first. Used by skillify to mine a project's decisions
580    /// and insights across all its agents (#290).
581    pub fn load_all_for_project(project_root: &str) -> Vec<AgentDiary> {
582        let Ok(dir) = diary_dir() else {
583            return Vec::new();
584        };
585        if !dir.exists() {
586            return Vec::new();
587        }
588        let want = project_root.trim_end_matches('/');
589        let mut diaries: Vec<AgentDiary> = Vec::new();
590        if let Ok(entries) = std::fs::read_dir(&dir) {
591            for entry in entries.flatten() {
592                if entry.path().extension().and_then(|e| e.to_str()) != Some("json") {
593                    continue;
594                }
595                if let Ok(content) = std::fs::read_to_string(entry.path())
596                    && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
597                    && diary.project_root.trim_end_matches('/') == want
598                {
599                    diaries.push(diary);
600                }
601            }
602        }
603        diaries.sort_by_key(|d| std::cmp::Reverse(d.updated_at));
604        diaries
605    }
606}
607
608impl std::fmt::Display for DiaryEntryType {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        match self {
611            DiaryEntryType::Discovery => write!(f, "discovery"),
612            DiaryEntryType::Decision => write!(f, "decision"),
613            DiaryEntryType::Blocker => write!(f, "blocker"),
614            DiaryEntryType::Progress => write!(f, "progress"),
615            DiaryEntryType::Insight => write!(f, "insight"),
616        }
617    }
618}
619
620fn diary_dir() -> Result<PathBuf, String> {
621    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
622    Ok(dir.join("agents").join("diaries"))
623}
624
625fn sanitize_filename(name: &str) -> String {
626    name.chars()
627        .map(|c| {
628            if c.is_alphanumeric() || c == '-' || c == '_' {
629                c
630            } else {
631                '_'
632            }
633        })
634        .collect()
635}
636
637fn truncate(s: &str, max: usize) -> String {
638    if s.len() <= max {
639        s.to_string()
640    } else {
641        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
642    }
643}
644
645fn agents_dir() -> Result<PathBuf, String> {
646    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
647    Ok(dir.join("agents"))
648}
649
650fn mutate_persistent<T>(mutate: impl FnOnce(&mut AgentRegistry) -> T) -> Result<T, String> {
651    let dir = agents_dir()?;
652    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
653    let _lock = FileLock::acquire(&dir.join("registry.lock"))?;
654    let path = dir.join("registry.json");
655    let mut registry = std::fs::read_to_string(&path)
656        .ok()
657        .and_then(|content| serde_json::from_str(&content).ok())
658        .unwrap_or_default();
659    let result = mutate(&mut registry);
660    let json = serde_json::to_string_pretty(&registry).map_err(|e| e.to_string())?;
661    std::fs::write(path, json).map_err(|e| e.to_string())?;
662    Ok(result)
663}
664
665fn generate_short_id() -> String {
666    use std::collections::hash_map::DefaultHasher;
667    use std::hash::{Hash, Hasher};
668    use std::time::SystemTime;
669
670    let mut hasher = DefaultHasher::new();
671    SystemTime::now().hash(&mut hasher);
672    std::process::id().hash(&mut hasher);
673    format!("{:08x}", hasher.finish() as u32)
674}
675
676/// #576 already fixed this exact hardcoded-`true` anti-pattern for
677/// `daemon::is_daemon_running` by delegating to `ipc::process::is_alive`
678/// (which has a real Windows `OpenProcess` check); this duplicate copy was
679/// missed, so on non-unix targets `cleanup_stale` could never flip a dead
680/// MCP session's entry to `Finished`, leaving `registry.json` accumulating
681/// stale `Active` entries forever — the root cause of the "N active agents"
682/// dashboard bug on Windows.
683pub fn is_process_alive(pid: u32) -> bool {
684    crate::ipc::process::is_alive(pid)
685}
686
687pub(crate) struct FileLock {
688    path: PathBuf,
689}
690
691impl FileLock {
692    pub(crate) fn acquire(path: &std::path::Path) -> Result<Self, String> {
693        for _ in 0..50 {
694            if std::fs::OpenOptions::new()
695                .write(true)
696                .create_new(true)
697                .open(path)
698                .is_ok()
699            {
700                return Ok(Self {
701                    path: path.to_path_buf(),
702                });
703            }
704            if let Ok(metadata) = std::fs::metadata(path)
705                && let Ok(modified) = metadata.modified()
706                && modified.elapsed().unwrap_or_default().as_secs() > 5
707            {
708                let _ = std::fs::remove_file(path);
709                continue;
710            }
711            std::thread::sleep(std::time::Duration::from_millis(100));
712        }
713        Err("Could not acquire lock after 5 seconds".to_string())
714    }
715}
716
717impl Drop for FileLock {
718    fn drop(&mut self) {
719        let _ = std::fs::remove_file(&self.path);
720    }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct SharedFact {
725    pub from_agent: String,
726    pub category: String,
727    pub key: String,
728    pub value: String,
729    pub timestamp: DateTime<Utc>,
730    #[serde(default)]
731    pub received_by: Vec<String>,
732}
733
734impl AgentRegistry {
735    pub fn share_knowledge(&mut self, from: &str, category: &str, facts: &[(String, String)]) {
736        for (key, value) in facts {
737            self.scratchpad.push(ScratchpadEntry {
738                id: format!("knowledge-{}", chrono::Utc::now().timestamp_millis()),
739                from_agent: from.to_string(),
740                to_agent: None,
741                task_id: None,
742                category: category.to_string(),
743                priority: MessagePriority::default(),
744                privacy: PrivacyLevel::Team,
745                message: format!("[knowledge] {key}={value}"),
746                metadata: HashMap::new(),
747                project_root: None,
748                timestamp: Utc::now(),
749                read_by: Vec::new(),
750                expires_at: None,
751            });
752        }
753        let shared_path = Self::shared_knowledge_path();
754        let mut existing: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
755            .ok()
756            .and_then(|s| serde_json::from_str(&s).ok())
757            .unwrap_or_default();
758
759        for (key, value) in facts {
760            existing.push(SharedFact {
761                from_agent: from.to_string(),
762                category: category.to_string(),
763                key: key.clone(),
764                value: value.clone(),
765                timestamp: Utc::now(),
766                received_by: Vec::new(),
767            });
768        }
769
770        if existing.len() > 500 {
771            existing.drain(..existing.len() - 500);
772        }
773        if let Ok(json) = serde_json::to_string_pretty(&existing) {
774            let _ = std::fs::write(&shared_path, json);
775        }
776    }
777
778    pub fn receive_shared_knowledge(&mut self, agent_id: &str) -> Vec<SharedFact> {
779        let shared_path = Self::shared_knowledge_path();
780        let mut all: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
781            .ok()
782            .and_then(|s| serde_json::from_str(&s).ok())
783            .unwrap_or_default();
784
785        let mut new_facts = Vec::new();
786        for fact in &mut all {
787            if fact.from_agent != agent_id && !fact.received_by.contains(&agent_id.to_string()) {
788                fact.received_by.push(agent_id.to_string());
789                new_facts.push(fact.clone());
790            }
791        }
792
793        if !new_facts.is_empty()
794            && let Ok(json) = serde_json::to_string_pretty(&all)
795        {
796            let _ = std::fs::write(&shared_path, json);
797        }
798        new_facts
799    }
800
801    fn shared_knowledge_path() -> PathBuf {
802        // GH #439: route through the typed data resolver so a post-migration
803        // split install writes to $XDG_DATA_HOME, not a re-created ~/.lean-ctx.
804        crate::core::paths::data_dir()
805            .unwrap_or_else(|_| PathBuf::from("."))
806            .join("shared_knowledge.json")
807    }
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(rename_all = "snake_case")]
812pub enum AgentRole {
813    Coder,
814    Reviewer,
815    Planner,
816    Explorer,
817    Debugger,
818    Tester,
819    Orchestrator,
820}
821
822impl AgentRole {
823    pub fn from_str_loose(s: &str) -> Self {
824        match s.to_lowercase().as_str() {
825            "review" | "reviewer" | "code_review" => Self::Reviewer,
826            "plan" | "planner" | "architect" => Self::Planner,
827            "explore" | "explorer" | "research" => Self::Explorer,
828            "debug" | "debugger" => Self::Debugger,
829            "test" | "tester" | "qa" => Self::Tester,
830            "orchestrator" | "coordinator" | "manager" => Self::Orchestrator,
831            _ => Self::Coder,
832        }
833    }
834}
835
836#[derive(Debug, Clone)]
837pub struct ContextDepthConfig {
838    pub max_files_full: usize,
839    pub max_files_signatures: usize,
840    pub preferred_mode: &'static str,
841    pub include_graph: bool,
842    pub include_knowledge: bool,
843    pub include_gotchas: bool,
844    pub context_budget_ratio: f64,
845}
846
847impl ContextDepthConfig {
848    pub fn for_role(role: AgentRole) -> Self {
849        match role {
850            AgentRole::Coder => Self {
851                max_files_full: 5,
852                max_files_signatures: 15,
853                preferred_mode: "full",
854                include_graph: true,
855                include_knowledge: true,
856                include_gotchas: true,
857                context_budget_ratio: 0.7,
858            },
859            AgentRole::Reviewer => Self {
860                max_files_full: 3,
861                max_files_signatures: 20,
862                preferred_mode: "signatures",
863                include_graph: true,
864                include_knowledge: true,
865                include_gotchas: true,
866                context_budget_ratio: 0.5,
867            },
868            AgentRole::Planner => Self {
869                max_files_full: 1,
870                max_files_signatures: 10,
871                preferred_mode: "map",
872                include_graph: true,
873                include_knowledge: true,
874                include_gotchas: false,
875                context_budget_ratio: 0.3,
876            },
877            AgentRole::Explorer => Self {
878                max_files_full: 2,
879                max_files_signatures: 8,
880                preferred_mode: "map",
881                include_graph: true,
882                include_knowledge: false,
883                include_gotchas: false,
884                context_budget_ratio: 0.4,
885            },
886            AgentRole::Debugger => Self {
887                max_files_full: 8,
888                max_files_signatures: 5,
889                preferred_mode: "full",
890                include_graph: false,
891                include_knowledge: true,
892                include_gotchas: true,
893                context_budget_ratio: 0.8,
894            },
895            AgentRole::Tester => Self {
896                max_files_full: 4,
897                max_files_signatures: 10,
898                preferred_mode: "full",
899                include_graph: false,
900                include_knowledge: false,
901                include_gotchas: true,
902                context_budget_ratio: 0.6,
903            },
904            AgentRole::Orchestrator => Self {
905                max_files_full: 0,
906                max_files_signatures: 5,
907                preferred_mode: "map",
908                include_graph: true,
909                include_knowledge: true,
910                include_gotchas: false,
911                context_budget_ratio: 0.2,
912            },
913        }
914    }
915
916    pub fn mode_for_rank(&self, rank: usize) -> &'static str {
917        if rank < self.max_files_full {
918            "full"
919        } else if rank < self.max_files_full + self.max_files_signatures {
920            "signatures"
921        } else {
922            "map"
923        }
924    }
925}
926
927impl From<ScratchpadEntry> for A2AMessage {
928    fn from(entry: ScratchpadEntry) -> Self {
929        Self {
930            id: entry.id,
931            from_agent: entry.from_agent,
932            to_agent: entry.to_agent,
933            task_id: entry.task_id,
934            category: MessageCategory::parse_str(&entry.category),
935            priority: entry.priority,
936            privacy: entry.privacy,
937            content: entry.message,
938            metadata: entry.metadata,
939            project_root: entry.project_root,
940            timestamp: entry.timestamp,
941            read_by: entry.read_by,
942            expires_at: entry.expires_at,
943        }
944    }
945}
946
947impl From<A2AMessage> for ScratchpadEntry {
948    fn from(msg: A2AMessage) -> Self {
949        Self {
950            id: msg.id,
951            from_agent: msg.from_agent,
952            to_agent: msg.to_agent,
953            task_id: msg.task_id,
954            category: msg.category.to_string(),
955            priority: msg.priority,
956            privacy: msg.privacy,
957            message: msg.content,
958            metadata: msg.metadata,
959            project_root: msg.project_root,
960            timestamp: msg.timestamp,
961            read_by: msg.read_by,
962            expires_at: msg.expires_at,
963        }
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    #[test]
972    fn register_and_list() {
973        let mut reg = AgentRegistry::new();
974        let id = reg.register("cursor", Some("dev"), "/tmp/project");
975        assert!(!id.is_empty());
976        assert_eq!(reg.list_active(None).len(), 1);
977        assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
978    }
979
980    #[test]
981    fn reregister_same_pid() {
982        let mut reg = AgentRegistry::new();
983        let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
984        let id2 = reg.register("cursor", Some("review"), "/tmp/project");
985        assert_eq!(id1, id2);
986        assert_eq!(reg.agents.len(), 1);
987        assert_eq!(reg.agents[0].role, Some("review".to_string()));
988    }
989
990    #[test]
991    fn post_and_read_messages() {
992        let mut reg = AgentRegistry::new();
993        reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
994        reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
995
996        let msgs = reg.read_unread("agent-a");
997        assert_eq!(msgs.len(), 1);
998        assert_eq!(msgs[0].category, "request");
999    }
1000
1001    #[test]
1002    fn set_status() {
1003        let mut reg = AgentRegistry::new();
1004        let id = reg.register("claude", None, "/tmp/project");
1005        reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
1006        assert_eq!(reg.agents[0].status, AgentStatus::Idle);
1007        assert_eq!(
1008            reg.agents[0].status_message,
1009            Some("waiting for review".to_string())
1010        );
1011    }
1012
1013    #[test]
1014    fn broadcast_message() {
1015        let mut reg = AgentRegistry::new();
1016        reg.post_message("agent-a", None, "status", "Starting refactor");
1017
1018        let msgs_b = reg.read_unread("agent-b");
1019        assert_eq!(msgs_b.len(), 1);
1020        assert_eq!(msgs_b[0].message, "Starting refactor");
1021
1022        let msgs_a = reg.read_unread("agent-a");
1023        assert!(msgs_a.is_empty());
1024    }
1025
1026    #[test]
1027    fn diary_add_and_format() {
1028        let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
1029        diary.add_entry(
1030            DiaryEntryType::Discovery,
1031            "Found auth module at src/auth.rs",
1032            Some("auth"),
1033        );
1034        diary.add_entry(
1035            DiaryEntryType::Decision,
1036            "Use JWT RS256 for token signing",
1037            None,
1038        );
1039        diary.add_entry(
1040            DiaryEntryType::Progress,
1041            "Implemented login endpoint",
1042            Some("auth"),
1043        );
1044
1045        assert_eq!(diary.entries.len(), 3);
1046
1047        let summary = diary.format_summary();
1048        assert!(summary.contains("test-agent-001"));
1049        assert!(summary.contains("FOUND"));
1050        assert!(summary.contains("DECIDED"));
1051        assert!(summary.contains("DONE"));
1052    }
1053
1054    #[test]
1055    fn diary_compact_format() {
1056        let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
1057        diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
1058        diary.add_entry(
1059            DiaryEntryType::Blocker,
1060            "Missing API credentials",
1061            Some("deploy"),
1062        );
1063
1064        let compact = diary.format_compact();
1065        assert!(compact.contains("diary:test-agent-002"));
1066        assert!(compact.contains("B:Missing API credentials"));
1067        assert!(compact.contains("I:DB queries are N+1"));
1068    }
1069
1070    #[test]
1071    fn diary_entry_types() {
1072        let types = vec![
1073            DiaryEntryType::Discovery,
1074            DiaryEntryType::Decision,
1075            DiaryEntryType::Blocker,
1076            DiaryEntryType::Progress,
1077            DiaryEntryType::Insight,
1078        ];
1079        for t in types {
1080            assert!(!format!("{t}").is_empty());
1081        }
1082    }
1083
1084    #[test]
1085    fn diary_truncation() {
1086        let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
1087        for i in 0..150 {
1088            diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
1089        }
1090        assert!(diary.entries.len() <= 100);
1091    }
1092
1093    #[test]
1094    fn truncate_utf8_emoji_no_panic() {
1095        let result = truncate("Agent 🤖 Name ist lang genug", 15);
1096        assert!(result.ends_with("..."));
1097    }
1098
1099    #[test]
1100    fn truncate_utf8_cyrillic_no_panic() {
1101        let result = truncate("агент выполняет длинную задачу", 15);
1102        assert!(result.ends_with("..."));
1103    }
1104
1105    #[test]
1106    fn truncate_short_utf8_unchanged() {
1107        assert_eq!(truncate("短い", 20), "短い");
1108    }
1109
1110    fn test_entry(agent_id: &str, project_root: &str, pid: u32) -> AgentEntry {
1111        let now = Utc::now();
1112        AgentEntry {
1113            agent_id: agent_id.to_string(),
1114            agent_type: "cursor".to_string(),
1115            role: Some("dev".to_string()),
1116            project_root: project_root.to_string(),
1117            started_at: now,
1118            last_active: now,
1119            pid,
1120            status: AgentStatus::Active,
1121            status_message: None,
1122        }
1123    }
1124
1125    /// #419: the wake-up briefing scopes agents to the current project via
1126    /// `list_active(Some(root))`. Peers working on *other* projects must never
1127    /// leak into the briefing.
1128    #[test]
1129    fn list_active_scopes_to_project_root() {
1130        let mut reg = AgentRegistry::new();
1131        reg.agents
1132            .push(test_entry("a-1", "/proj/a", std::process::id()));
1133        reg.agents
1134            .push(test_entry("b-1", "/proj/b", std::process::id()));
1135
1136        let active_a = reg.list_active(Some("/proj/a"));
1137        assert_eq!(active_a.len(), 1);
1138        assert_eq!(active_a[0].agent_id, "a-1");
1139
1140        // Unscoped still sees both.
1141        assert_eq!(reg.list_active(None).len(), 2);
1142    }
1143
1144    /// #419: a crashed/exited MCP process leaves an `Active` entry behind.
1145    /// `cleanup_stale` must flip it to `Finished` (regardless of age) so
1146    /// `list_active` no longer surfaces it as a live peer — the ghost the
1147    /// briefing used to show. Previously `#[cfg(unix)]`-only, which is why
1148    /// the non-unix `is_process_alive` hardcoded-`true` regression (see its
1149    /// doc comment) shipped unnoticed: this exact test never ran on Windows.
1150    #[test]
1151    fn cleanup_stale_prunes_dead_pid_from_active_list() {
1152        // Reap a child so its PID is guaranteed dead at assertion time.
1153        let reaped = {
1154            let mut cmd = if cfg!(windows) {
1155                let mut c = std::process::Command::new("cmd");
1156                c.args(["/C", "exit"]);
1157                c
1158            } else {
1159                std::process::Command::new("true")
1160            };
1161            let mut child = cmd.spawn().expect("spawn short-lived helper process");
1162            let pid = child.id();
1163            child.wait().expect("reap helper process");
1164            pid
1165        };
1166
1167        let mut reg = AgentRegistry::new();
1168        reg.agents.push(test_entry("ghost", "/proj/a", reaped));
1169        reg.agents
1170            .push(test_entry("live", "/proj/a", std::process::id()));
1171
1172        reg.cleanup_stale(24);
1173
1174        let ids: Vec<&str> = reg
1175            .list_active(Some("/proj/a"))
1176            .iter()
1177            .map(|a| a.agent_id.as_str())
1178            .collect();
1179        assert!(ids.contains(&"live"), "live same-project agent must remain");
1180        assert!(
1181            !ids.contains(&"ghost"),
1182            "dead-pid agent must be pruned from the active list (#419)"
1183        );
1184    }
1185
1186    /// Regression: concurrent load-mutate-save cycles must not silently drop
1187    /// each other's changes. Before `mutate_locked`, `save()` only locked the
1188    /// final write — the preceding `load()` was unlocked, so a second writer
1189    /// could load a stale snapshot and overwrite the first writer's addition
1190    /// (e.g. a second Claude Code session's agent registration vanishing
1191    /// from the dashboard).
1192    #[test]
1193    fn mutate_locked_survives_concurrent_writers() {
1194        let _iso = crate::core::data_dir::isolated_data_dir();
1195
1196        let handles: Vec<_> = (0..8)
1197            .map(|i| {
1198                std::thread::spawn(move || {
1199                    AgentRegistry::mutate_locked(|registry| {
1200                        registry.agents.push(AgentEntry {
1201                            agent_id: format!("agent-{i}"),
1202                            agent_type: "test".to_string(),
1203                            role: None,
1204                            project_root: "/tmp/project".to_string(),
1205                            started_at: Utc::now(),
1206                            last_active: Utc::now(),
1207                            pid: 10_000 + i,
1208                            status: AgentStatus::Active,
1209                            status_message: None,
1210                        });
1211                    })
1212                })
1213            })
1214            .collect();
1215
1216        for h in handles {
1217            h.join()
1218                .expect("writer thread must not panic")
1219                .expect("mutate_locked must succeed");
1220        }
1221
1222        let registry = AgentRegistry::load_or_create();
1223        assert_eq!(
1224            registry.agents.len(),
1225            8,
1226            "all 8 concurrent registrations must survive, got {}",
1227            registry.agents.len()
1228        );
1229    }
1230}
1231
1232#[cfg(test)]
1233mod presence_tests {
1234    use super::*;
1235
1236    #[test]
1237    fn persistent_presence_preserves_multiple_processes_and_lifecycle() {
1238        let isolated = crate::core::data_dir::isolated_data_dir();
1239        let mut registry = AgentRegistry::new();
1240        let first = registry.register_process("mcp", Some("context-engine"), "/project", 101);
1241        let second = registry.register_process("mcp", Some("context-engine"), "/project", 202);
1242        registry.save().expect("save registry");
1243
1244        assert_ne!(first, second);
1245        assert_eq!(AgentRegistry::load().expect("registry").agents.len(), 2);
1246
1247        AgentRegistry::heartbeat_persistent(&first).expect("heartbeat");
1248        AgentRegistry::finish_persistent(&second).expect("finish");
1249        let loaded = AgentRegistry::load().expect("registry");
1250        assert_eq!(
1251            loaded
1252                .agents
1253                .iter()
1254                .find(|agent| agent.agent_id == second)
1255                .expect("second agent")
1256                .status,
1257            AgentStatus::Finished
1258        );
1259        assert!(isolated.path().join("agents/registry.json").exists());
1260    }
1261
1262    #[test]
1263    fn reregistering_process_refreshes_metadata_without_duplication() {
1264        let mut registry = AgentRegistry::new();
1265        let first = registry.register_process("unknown", None, "/old", 303);
1266        let second = registry.register_process("mcp", Some("context-engine"), "/new", 303);
1267
1268        assert_eq!(first, second);
1269        assert_eq!(registry.agents.len(), 1);
1270        assert_eq!(registry.agents[0].agent_type, "mcp");
1271        assert_eq!(registry.agents[0].project_root, "/new");
1272        assert_eq!(registry.agents[0].role.as_deref(), Some("context-engine"));
1273    }
1274
1275    #[test]
1276    fn logical_sessions_are_keyed_independently_of_transport_processes() {
1277        let mut registry = AgentRegistry::new();
1278        registry.register_process("mcp", Some("context-engine"), "/project", 303);
1279        registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1280        registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-b");
1281        let opened_at = registry.logical_sessions[0].opened_at;
1282
1283        registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1284
1285        assert_eq!(registry.agents.len(), 1);
1286        assert_eq!(registry.logical_sessions.len(), 2);
1287        assert_eq!(registry.logical_sessions[0].opened_at, opened_at);
1288        assert!(registry.logical_session_telemetry_seen);
1289        assert!(registry.close_logical_session("vscode", "/project", "chat-b"));
1290        assert_eq!(registry.logical_sessions.len(), 1);
1291    }
1292
1293    #[test]
1294    fn logical_session_expiry_is_bounded_by_heartbeat_not_tool_activity() {
1295        let mut registry = AgentRegistry::new();
1296        registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1297        registry.logical_sessions[0].last_heartbeat = Utc::now() - chrono::Duration::seconds(181);
1298
1299        registry.cleanup_stale_logical_sessions(180);
1300
1301        assert!(registry.logical_sessions.is_empty());
1302        assert!(registry.logical_session_telemetry_seen);
1303    }
1304
1305    #[test]
1306    fn legacy_registry_deserializes_without_claiming_session_telemetry() {
1307        let registry: AgentRegistry = serde_json::from_str(
1308            r#"{"agents":[],"scratchpad":[],"updated_at":"2026-01-01T00:00:00Z"}"#,
1309        )
1310        .expect("legacy registry");
1311
1312        assert!(registry.logical_sessions.is_empty());
1313        assert!(!registry.logical_session_telemetry_seen);
1314    }
1315}