mi6_core/framework/
amp.rs

1//! Amp coding agent framework adapter.
2//!
3//! This adapter handles integration with the Amp coding agent (<https://ampcode.com>).
4//!
5//! # Process-Only Detection
6//!
7//! Unlike other frameworks (Claude, Gemini, etc.), Amp does not expose hook APIs
8//! that allow mi6 to receive events for session lifecycle, tool usage, or token
9//! tracking. Instead, mi6 discovers Amp sessions through process scanning:
10//!
11//! 1. Periodically scan for running processes named "amp"
12//! 2. Verify the executable path is `~/.amp/bin/amp` to avoid false positives
13//! 3. Extract the real thread ID from `~/.cache/amp/logs/cli.log`
14//! 4. Create sessions in the database with CPU/memory metrics
15//!
16//! # Limitations
17//!
18//! Due to the lack of hook integration, Amp sessions have limited data:
19//!
20//! | Feature               | Status                           |
21//! |-----------------------|----------------------------------|
22//! | Session detection     | ✅ Via process scanning          |
23//! | CPU/Memory metrics    | ✅ Full support                  |
24//! | Status tracking       | ❌ Always shows "n/a"            |
25//! | Initial prompt        | ❌ Always shows "n/a"            |
26//! | Token counting        | ❌ Not available                 |
27//! | Tool call tracking    | ❌ Not available                 |
28//! | Git context (branch)  | ✅ From working directory        |
29//!
30//! # No Enable/Disable
31//!
32//! Since Amp uses process-based discovery, there's nothing to enable or disable.
33//! Amp sessions will appear in mi6 automatically when amp processes are running.
34
35use super::{FrameworkAdapter, ParsedHookInput};
36use crate::model::EventType;
37use std::path::PathBuf;
38
39/// Amp coding agent framework adapter.
40///
41/// This adapter is primarily used for framework identification and process
42/// detection. Since Amp doesn't support hooks, most hook-related methods
43/// return minimal/empty values.
44pub struct AmpAdapter;
45
46impl FrameworkAdapter for AmpAdapter {
47    fn name(&self) -> &'static str {
48        "amp"
49    }
50
51    fn display_name(&self) -> &'static str {
52        "Amp"
53    }
54
55    fn project_config_path(&self) -> PathBuf {
56        // Amp doesn't use project-level config for hooks
57        PathBuf::from(".config/amp/settings.json")
58    }
59
60    fn user_config_path(&self) -> Option<PathBuf> {
61        dirs::home_dir().map(|h| h.join(".config/amp/settings.json"))
62    }
63
64    fn generate_hooks_config(
65        &self,
66        _enabled_events: &[EventType],
67        _mi6_bin: &str,
68        _otel_enabled: bool,
69        _otel_port: u16,
70    ) -> serde_json::Value {
71        // Amp doesn't support hooks - return informational message
72        serde_json::json!({
73            "_note": "Amp does not support hooks. Sessions are detected via process scanning.",
74            "status": "n/a",
75            "prompt": "n/a"
76        })
77    }
78
79    fn merge_config(
80        &self,
81        generated: serde_json::Value,
82        _existing: Option<serde_json::Value>,
83    ) -> serde_json::Value {
84        // No merging needed - Amp doesn't use hook config
85        generated
86    }
87
88    fn parse_hook_input(
89        &self,
90        _event_type: &str,
91        stdin_json: &serde_json::Value,
92    ) -> ParsedHookInput {
93        // Amp doesn't send hook events, but if called, extract what we can
94        ParsedHookInput {
95            session_id: stdin_json
96                .get("session_id")
97                .and_then(|v| v.as_str())
98                .map(String::from),
99            tool_name: stdin_json
100                .get("tool_name")
101                .and_then(|v| v.as_str())
102                .map(String::from),
103            cwd: stdin_json
104                .get("cwd")
105                .and_then(|v| v.as_str())
106                .map(String::from),
107            tool_use_id: None,
108            subagent_type: None,
109            spawned_agent_id: None,
110            permission_mode: None,
111            transcript_path: None,
112            session_source: None,
113            agent_id: None,
114            agent_transcript_path: None,
115            compact_trigger: None,
116            model: None,
117            duration_ms: None,
118            tokens_input: None,
119            tokens_output: None,
120            tokens_cache_read: None,
121            tokens_cache_write: None,
122            cost_usd: None,
123            prompt: None,
124        }
125    }
126
127    fn map_event_type(&self, framework_event: &str) -> EventType {
128        // Amp doesn't send events, but map if somehow called
129        framework_event
130            .parse()
131            .unwrap_or_else(|_| EventType::Custom(framework_event.to_string()))
132    }
133
134    fn supported_events(&self) -> Vec<&'static str> {
135        // Amp doesn't support hook events
136        vec![]
137    }
138
139    fn detection_env_vars(&self) -> &[&'static str] {
140        // Amp doesn't set specific env vars for detection.
141        // Detection uses executable path (~/.amp/bin/amp) instead.
142        &[]
143    }
144
145    fn is_installed(&self) -> bool {
146        // Check if amp directory or binary exists
147        let amp_dir_exists = dirs::home_dir().is_some_and(|h| h.join(".amp").exists());
148        let config_dir_exists = dirs::home_dir().is_some_and(|h| h.join(".config/amp").exists());
149        let amp_binary_exists = dirs::home_dir().is_some_and(|h| h.join(".amp/bin/amp").exists());
150
151        amp_dir_exists || config_dir_exists || amp_binary_exists || which::which("amp").is_ok()
152    }
153
154    fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
155        // Amp doesn't use hooks - nothing to remove
156        None
157    }
158
159    fn has_mi6_hooks(&self, _local: bool, _settings_local: bool) -> bool {
160        // Amp uses process detection, not hooks
161        // Return true if amp is installed so it appears in status
162        self.is_installed()
163    }
164
165    fn resume_command(&self, _session_id: &str) -> Option<String> {
166        // Amp doesn't have a CLI resume command
167        None
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_name() {
177        let adapter = AmpAdapter;
178        assert_eq!(adapter.name(), "amp");
179        assert_eq!(adapter.display_name(), "Amp");
180    }
181
182    #[test]
183    fn test_detection_env_vars() {
184        let adapter = AmpAdapter;
185        let vars = adapter.detection_env_vars();
186        // Amp doesn't use env vars for detection - uses executable path instead
187        assert!(vars.is_empty());
188    }
189
190    #[test]
191    fn test_supported_events_empty() {
192        let adapter = AmpAdapter;
193        assert!(adapter.supported_events().is_empty());
194    }
195
196    #[test]
197    fn test_map_event_type_unknown() {
198        let adapter = AmpAdapter;
199        assert_eq!(
200            adapter.map_event_type("SomeEvent"),
201            EventType::Custom("SomeEvent".to_string())
202        );
203    }
204
205    #[test]
206    fn test_parse_hook_input() {
207        let adapter = AmpAdapter;
208        let input = serde_json::json!({
209            "session_id": "T-123",
210            "tool_name": "Bash",
211            "cwd": "/projects/test"
212        });
213
214        let parsed = adapter.parse_hook_input("PreToolUse", &input);
215        assert_eq!(parsed.session_id, Some("T-123".to_string()));
216        assert_eq!(parsed.tool_name, Some("Bash".to_string()));
217        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
218    }
219}