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