Skip to main content

nexus_memory_hooks/agents/
cli.rs

1//! Generic CLI hook implementation for agents without native hooks
2//!
3//! Uses atexit/signals for detection.
4
5use async_trait::async_trait;
6use std::path::PathBuf;
7
8use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
9use crate::error::Result;
10use crate::monitor::ProcessMonitor;
11use crate::session::SessionContext;
12use crate::types::{AgentType, SessionActivity, SupportTier};
13
14/// Generic CLI hook using atexit and signal handling
15///
16/// Used for agents that don't have native hook support:
17/// - OpenCode
18/// - Codex
19/// - Amp
20/// - Droid
21/// - Hermes
22/// - Other generic agents
23pub struct CLIHook {
24    /// Base hook functionality
25    base: BaseHook,
26
27    /// Agent type name
28    agent_type_name: String,
29
30    /// Agent type enum
31    agent_type: AgentType,
32
33    /// Process monitor
34    process_monitor: ProcessMonitor,
35}
36
37impl CLIHook {
38    /// Create a new CLI hook for the given agent type
39    pub fn new(agent_type: impl Into<String>) -> Self {
40        let agent_type_name = agent_type.into();
41        let agent_type = AgentType::parse(&agent_type_name).unwrap_or(AgentType::Generic);
42
43        Self {
44            base: BaseHook::new(&agent_type_name),
45            agent_type_name,
46            agent_type,
47            process_monitor: ProcessMonitor::new(),
48        }
49    }
50
51    /// Get config path for this agent
52    fn config_path(&self) -> PathBuf {
53        dirs::home_dir()
54            .unwrap_or_else(|| PathBuf::from("."))
55            .join(self.agent_type.config_dir())
56    }
57
58    /// Read session data if available
59    fn read_session_data(&self) -> Option<serde_json::Value> {
60        let session_file = self.config_path().join("session.json");
61
62        if session_file.exists() {
63            let content = std::fs::read_to_string(&session_file).ok()?;
64            serde_json::from_str(&content).ok()
65        } else {
66            None
67        }
68    }
69}
70
71#[async_trait]
72impl AgentHook for CLIHook {
73    fn agent_type(&self) -> &str {
74        &self.base.agent_type
75    }
76
77    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
78        self.base.add_callback(callback);
79        self.base.installed = true;
80
81        // CLI hooks rely on atexit and signal handlers
82        // These are set up by the signal module when the extractor starts
83
84        Ok(())
85    }
86
87    async fn detect_session_activity(&self) -> Result<SessionActivity> {
88        let mut monitor = self.process_monitor.clone();
89        let processes = monitor.find_agent_processes(self.agent_type);
90
91        let mut activity = SessionActivity::new(self.agent_type);
92
93        if !processes.is_empty() {
94            activity.is_active = true;
95            activity.processes = processes;
96        }
97
98        // Check session directory for recent activity
99        let session_dir = self.config_path().join("sessions");
100        if session_dir.exists() {
101            if let Ok(entries) = std::fs::read_dir(&session_dir) {
102                let most_recent = entries
103                    .filter_map(|e| e.ok())
104                    .filter(|e| {
105                        e.path()
106                            .extension()
107                            .map(|ext| ext == "json")
108                            .unwrap_or(false)
109                    })
110                    .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
111
112                if let Some(entry) = most_recent {
113                    if let Ok(metadata) = entry.metadata() {
114                        if let Ok(modified) = metadata.modified() {
115                            let age = std::time::SystemTime::now()
116                                .duration_since(modified)
117                                .unwrap_or(std::time::Duration::MAX);
118
119                            // Consider active if modified in last 5 minutes
120                            if age.as_secs() < 300 {
121                                activity.is_active = true;
122                                activity.session_id = Some(
123                                    entry
124                                        .path()
125                                        .file_stem()
126                                        .unwrap()
127                                        .to_string_lossy()
128                                        .to_string(),
129                                );
130                            }
131                        }
132                    }
133                }
134            }
135        }
136
137        Ok(activity)
138    }
139
140    async fn extract_session_context(&self) -> Result<SessionContext> {
141        let mut context = SessionContext::new(&self.agent_type_name)
142            .with_source("cli")
143            .with_reliability(0.95);
144
145        // Read session data if available
146        if let Some(session) = self.read_session_data() {
147            if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
148                for msg in messages {
149                    let role = msg
150                        .get("role")
151                        .and_then(|r| r.as_str())
152                        .unwrap_or("unknown");
153                    let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
154                    context.add_message(role, content);
155                }
156            }
157
158            if let Some(commands) = session.get("commands").and_then(|c| c.as_array()) {
159                for cmd in commands {
160                    if let Some(cmd_str) = cmd.as_str() {
161                        context.add_command(cmd_str);
162                    }
163                }
164            }
165        }
166
167        // Try to get git status for modified files
168        let git_status = std::process::Command::new("git")
169            .args(["status", "--porcelain"])
170            .output()
171            .ok();
172
173        if let Some(output) = git_status {
174            if output.status.success() {
175                let status = String::from_utf8_lossy(&output.stdout);
176                for line in status.lines() {
177                    if line.len() > 3 {
178                        let status_char = line.chars().next().unwrap_or(' ');
179                        let file_path = &line[3..];
180                        let action = match status_char {
181                            '?' => crate::session::FileAction::Created,
182                            'D' => crate::session::FileAction::Deleted,
183                            _ => crate::session::FileAction::Modified,
184                        };
185                        context.add_file(crate::session::FileInfo::new(file_path, action));
186                    }
187                }
188            }
189        }
190
191        context.complete();
192        Ok(context)
193    }
194
195    fn is_hook_installed(&self) -> bool {
196        self.base.installed
197    }
198
199    fn reliability_score(&self) -> f32 {
200        0.95 // CLI hooks have 95% reliability
201    }
202
203    fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
204        // CLIHook registers an atexit callback in install_session_end_hook,
205        // so session_end is genuinely supported even without native hooks.
206        LifecycleCapabilities::end_only()
207    }
208
209    fn support_tier(&self) -> SupportTier {
210        SupportTier::WrapperLifecycle
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_cli_hook_new() {
220        let hook = CLIHook::new("opencode");
221        assert_eq!(hook.agent_type(), "opencode");
222
223        let hermes = CLIHook::new("hermes");
224        assert_eq!(hermes.agent_type(), "hermes");
225    }
226
227    #[tokio::test]
228    async fn test_cli_hook_detect_activity() {
229        let hook = CLIHook::new("codex");
230        let activity = hook.detect_session_activity().await.unwrap();
231
232        assert_eq!(activity.agent_type, AgentType::Codex);
233    }
234
235    #[test]
236    fn test_cli_hook_lifecycle_capabilities() {
237        let hook = CLIHook::new("codex");
238        let caps = hook.lifecycle_capabilities();
239
240        assert!(
241            !caps.session_start,
242            "CLI agents do not support session_start"
243        );
244        assert!(
245            caps.session_end,
246            "CLI agents support session_end via atexit callback"
247        );
248        assert!(!caps.checkpoint, "CLI agents do not support checkpoint");
249        assert!(!caps.compact, "CLI agents do not support compact");
250        assert!(!caps.error_hook, "CLI agents do not support error_hook");
251    }
252}