mi6_core/framework/
pi.rs

1//! Pi Coding Agent framework adapter.
2//!
3//! This adapter handles integration with the Pi Coding Agent
4//! (<https://github.com/badlogic/pi-mono>).
5//!
6//! Pi uses a TypeScript extension system rather than JSON/TOML hooks.
7//! mi6 integrates by installing a TypeScript extension that subscribes
8//! to Pi's event bus and forwards events to `mi6 ingest event`.
9//!
10//! # Installation Flow
11//!
12//! 1. `mi6 enable pi` writes the embedded TypeScript extension to
13//!    `~/.pi/agent/extensions/mi6.ts` (user scope) or
14//!    `.pi/extensions/mi6.ts` (project scope)
15//! 2. Pi automatically discovers and loads extensions from these directories
16//! 3. The extension sets `PI_SESSION_ID` env var for framework detection
17//!
18//! # Uninstallation
19//!
20//! `mi6 disable pi` removes the extension file.
21
22use super::{FrameworkAdapter, ParsedHookInput, common};
23use crate::model::EventType;
24use crate::model::error::InitError;
25use std::path::{Path, PathBuf};
26
27/// Embedded mi6.ts extension content.
28const MI6_EXTENSION_TS: &str = include_str!("pi-extension/mi6.ts");
29
30/// Pi Coding Agent framework adapter.
31///
32/// Handles extension installation and event parsing for Pi.
33pub struct PiAdapter;
34
35impl FrameworkAdapter for PiAdapter {
36    fn name(&self) -> &'static str {
37        "pi"
38    }
39
40    fn display_name(&self) -> &'static str {
41        "Pi Coding Agent"
42    }
43
44    fn project_config_path(&self) -> PathBuf {
45        PathBuf::from(".pi/extensions/mi6.ts")
46    }
47
48    fn user_config_path(&self) -> Option<PathBuf> {
49        dirs::home_dir().map(|h| h.join(".pi/agent/extensions/mi6.ts"))
50    }
51
52    fn generate_hooks_config(
53        &self,
54        _enabled_events: &[EventType],
55        _mi6_bin: &str,
56        _otel_enabled: bool,
57        _otel_port: u16,
58    ) -> serde_json::Value {
59        // Pi uses TypeScript extensions, not JSON config.
60        // Return a placeholder that describes what will be installed.
61        serde_json::json!({
62            "_note": "Pi uses TypeScript extensions, not JSON hooks",
63            "extension_path": "~/.pi/agent/extensions/mi6.ts",
64            "extension_content": "See embedded mi6.ts"
65        })
66    }
67
68    fn merge_config(
69        &self,
70        generated: serde_json::Value,
71        _existing: Option<serde_json::Value>,
72    ) -> serde_json::Value {
73        // No merging needed - we write a standalone file
74        generated
75    }
76
77    fn parse_hook_input(
78        &self,
79        _event_type: &str,
80        stdin_json: &serde_json::Value,
81    ) -> ParsedHookInput {
82        // Pi extension sends JSON with these fields:
83        // {
84        //   "session_id": "uuid",
85        //   "tool_name": "Bash",
86        //   "tool_use_id": "...",
87        //   "cwd": "/path/to/project",
88        //   "trigger": "auto" (for PreCompact),
89        //   "model": "anthropic/claude-sonnet-4-20250514",
90        //   "user_prompt": "hello there",
91        //   "transcript_path": "/path/to/session.jsonl"
92        // }
93        ParsedHookInput {
94            session_id: stdin_json
95                .get("session_id")
96                .and_then(|v| v.as_str())
97                .map(String::from),
98            tool_use_id: stdin_json
99                .get("tool_use_id")
100                .and_then(|v| v.as_str())
101                .map(String::from),
102            tool_name: stdin_json
103                .get("tool_name")
104                .and_then(|v| v.as_str())
105                .map(String::from),
106            cwd: stdin_json
107                .get("cwd")
108                .and_then(|v| v.as_str())
109                .map(String::from),
110            compact_trigger: stdin_json
111                .get("trigger")
112                .and_then(|v| v.as_str())
113                .map(String::from),
114            model: stdin_json
115                .get("model")
116                .and_then(|v| v.as_str())
117                .map(String::from),
118            transcript_path: stdin_json
119                .get("transcript_path")
120                .and_then(|v| v.as_str())
121                .map(String::from),
122            prompt: stdin_json
123                .get("user_prompt")
124                .and_then(|v| v.as_str())
125                .map(String::from),
126            // Token metrics from ApiRequest events
127            tokens_input: stdin_json.get("tokens_input").and_then(|v| v.as_i64()),
128            tokens_output: stdin_json.get("tokens_output").and_then(|v| v.as_i64()),
129            tokens_cache_read: stdin_json.get("tokens_cache_read").and_then(|v| v.as_i64()),
130            tokens_cache_write: stdin_json
131                .get("tokens_cache_write")
132                .and_then(|v| v.as_i64()),
133            cost_usd: stdin_json.get("cost_usd").and_then(|v| v.as_f64()),
134            // Pi doesn't have these Claude-specific features
135            subagent_type: None,
136            spawned_agent_id: None,
137            permission_mode: None,
138            session_source: None,
139            agent_id: None,
140            agent_transcript_path: None,
141            duration_ms: None,
142        }
143    }
144
145    fn map_event_type(&self, framework_event: &str) -> EventType {
146        // Pi extension sends canonical event names
147        framework_event
148            .parse()
149            .unwrap_or_else(|_| EventType::Custom(framework_event.to_string()))
150    }
151
152    fn supported_events(&self) -> Vec<&'static str> {
153        vec![
154            "SessionStart",
155            "SessionEnd",
156            "PreToolUse",
157            "PostToolUse",
158            "UserPromptSubmit",
159            "Stop",
160            "PreCompact",
161            "ApiRequest",
162        ]
163    }
164
165    fn detection_env_vars(&self) -> &[&'static str] {
166        // The mi6 extension sets this when Pi starts
167        &["PI_SESSION_ID"]
168    }
169
170    fn is_installed(&self) -> bool {
171        common::is_framework_installed(dirs::home_dir().map(|h| h.join(".pi/agent")), "pi")
172    }
173
174    fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
175        // Pi uses extension files, not JSON hooks
176        // Removal is handled by uninstall_hooks
177        None
178    }
179
180    // ========================================================================
181    // Extension-based installation
182    // ========================================================================
183
184    fn settings_path(&self, local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
185        if local {
186            Ok(self.project_config_path())
187        } else {
188            self.user_config_path()
189                .ok_or_else(|| InitError::Config("could not determine home directory".into()))
190        }
191    }
192
193    fn has_mi6_hooks(&self, local: bool, settings_local: bool) -> bool {
194        self.settings_path(local, settings_local)
195            .map(|p| p.exists())
196            .unwrap_or(false)
197    }
198
199    fn install_hooks(
200        &self,
201        path: &Path,
202        _hooks: &serde_json::Value,
203        _otel_env: Option<serde_json::Value>,
204        _remove_otel: bool,
205    ) -> Result<(), InitError> {
206        // Create parent directory if needed
207        if let Some(parent) = path.parent() {
208            std::fs::create_dir_all(parent).map_err(|e| {
209                InitError::Config(format!("failed to create {}: {e}", parent.display()))
210            })?;
211        }
212
213        // Write the TypeScript extension
214        std::fs::write(path, MI6_EXTENSION_TS)
215            .map_err(|e| InitError::Config(format!("failed to write {}: {e}", path.display())))?;
216
217        Ok(())
218    }
219
220    fn uninstall_hooks(&self, local: bool, settings_local: bool) -> Result<bool, InitError> {
221        let path = self.settings_path(local, settings_local)?;
222
223        if path.exists() {
224            std::fs::remove_file(&path).map_err(|e| {
225                InitError::Config(format!("failed to remove {}: {e}", path.display()))
226            })?;
227            Ok(true)
228        } else {
229            Ok(false)
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_name() {
240        let adapter = PiAdapter;
241        assert_eq!(adapter.name(), "pi");
242        assert_eq!(adapter.display_name(), "Pi Coding Agent");
243    }
244
245    #[test]
246    fn test_project_config_path() {
247        let adapter = PiAdapter;
248        assert_eq!(
249            adapter.project_config_path(),
250            PathBuf::from(".pi/extensions/mi6.ts")
251        );
252    }
253
254    #[test]
255    fn test_user_config_path() {
256        let adapter = PiAdapter;
257        let path = adapter.user_config_path();
258        assert!(path.is_some());
259        let path = path.unwrap();
260        assert!(path.ends_with(".pi/agent/extensions/mi6.ts"));
261    }
262
263    #[test]
264    fn test_map_event_type() {
265        let adapter = PiAdapter;
266
267        // Canonical events should parse correctly
268        assert_eq!(
269            adapter.map_event_type("SessionStart"),
270            EventType::SessionStart
271        );
272        assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
273        assert_eq!(
274            adapter.map_event_type("PostToolUse"),
275            EventType::PostToolUse
276        );
277        assert_eq!(
278            adapter.map_event_type("UserPromptSubmit"),
279            EventType::UserPromptSubmit
280        );
281        assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
282        assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
283
284        // Unknown events become Custom
285        assert_eq!(
286            adapter.map_event_type("UnknownEvent"),
287            EventType::Custom("UnknownEvent".to_string())
288        );
289    }
290
291    #[test]
292    fn test_parse_hook_input() {
293        let adapter = PiAdapter;
294        let input = serde_json::json!({
295            "session_id": "pi-session-123",
296            "tool_use_id": "tool-456",
297            "tool_name": "Bash",
298            "cwd": "/projects/test"
299        });
300
301        let parsed = adapter.parse_hook_input("PreToolUse", &input);
302
303        assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
304        assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
305        assert_eq!(parsed.tool_name, Some("Bash".to_string()));
306        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
307        // Pi doesn't provide Claude-specific fields
308        assert_eq!(parsed.permission_mode, None);
309        assert_eq!(parsed.subagent_type, None);
310        assert_eq!(parsed.spawned_agent_id, None);
311    }
312
313    #[test]
314    fn test_parse_hook_input_with_model_and_prompt() {
315        let adapter = PiAdapter;
316        let input = serde_json::json!({
317            "session_id": "pi-session-123",
318            "cwd": "/projects/test",
319            "model": "anthropic/claude-sonnet-4-20250514",
320            "user_prompt": "hello there",
321            "transcript_path": "/home/user/.pi/agent/sessions/test/session.jsonl"
322        });
323
324        let parsed = adapter.parse_hook_input("SessionStart", &input);
325
326        assert_eq!(parsed.session_id, Some("pi-session-123".to_string()));
327        assert_eq!(
328            parsed.model,
329            Some("anthropic/claude-sonnet-4-20250514".to_string())
330        );
331        assert_eq!(parsed.prompt, Some("hello there".to_string()));
332        assert_eq!(
333            parsed.transcript_path,
334            Some("/home/user/.pi/agent/sessions/test/session.jsonl".to_string())
335        );
336    }
337
338    #[test]
339    fn test_parse_hook_input_with_trigger() {
340        let adapter = PiAdapter;
341        let input = serde_json::json!({
342            "session_id": "pi-123",
343            "trigger": "auto"
344        });
345
346        let parsed = adapter.parse_hook_input("PreCompact", &input);
347
348        assert_eq!(parsed.session_id, Some("pi-123".to_string()));
349        assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
350    }
351
352    #[test]
353    fn test_parse_hook_input_with_tokens() {
354        let adapter = PiAdapter;
355        let input = serde_json::json!({
356            "session_id": "pi-123",
357            "model": "gpt-4.1",
358            "tokens_input": 942,
359            "tokens_output": 64,
360            "tokens_cache_read": 0,
361            "tokens_cache_write": 0,
362            "cost_usd": 0.00239
363        });
364
365        let parsed = adapter.parse_hook_input("ApiRequest", &input);
366
367        assert_eq!(parsed.session_id, Some("pi-123".to_string()));
368        assert_eq!(parsed.model, Some("gpt-4.1".to_string()));
369        assert_eq!(parsed.tokens_input, Some(942));
370        assert_eq!(parsed.tokens_output, Some(64));
371        assert_eq!(parsed.tokens_cache_read, Some(0));
372        assert_eq!(parsed.tokens_cache_write, Some(0));
373        assert!((parsed.cost_usd.unwrap() - 0.00239).abs() < 0.0001);
374    }
375
376    #[test]
377    fn test_supported_events() {
378        let adapter = PiAdapter;
379        let events = adapter.supported_events();
380
381        assert!(events.contains(&"SessionStart"));
382        assert!(events.contains(&"SessionEnd"));
383        assert!(events.contains(&"PreToolUse"));
384        assert!(events.contains(&"PostToolUse"));
385        assert!(events.contains(&"UserPromptSubmit"));
386        assert!(events.contains(&"Stop"));
387        assert!(events.contains(&"PreCompact"));
388        assert!(events.contains(&"ApiRequest"));
389        assert_eq!(events.len(), 8);
390    }
391
392    #[test]
393    fn test_detection_env_vars() {
394        let adapter = PiAdapter;
395        let vars = adapter.detection_env_vars();
396
397        assert!(vars.contains(&"PI_SESSION_ID"));
398        assert_eq!(vars.len(), 1);
399    }
400
401    #[test]
402    fn test_embedded_extension() {
403        // Verify the embedded extension uses correct Pi API patterns
404        assert!(MI6_EXTENSION_TS.contains("mi6"));
405        assert!(MI6_EXTENSION_TS.contains("ingestEvent"));
406        // Uses Pi's snake_case event names
407        assert!(MI6_EXTENSION_TS.contains("session_start"));
408        assert!(MI6_EXTENSION_TS.contains("session_shutdown"));
409        assert!(MI6_EXTENSION_TS.contains("tool_call"));
410        assert!(MI6_EXTENSION_TS.contains("tool_result"));
411        assert!(MI6_EXTENSION_TS.contains("turn_start"));
412        assert!(MI6_EXTENSION_TS.contains("turn_end"));
413        assert!(MI6_EXTENSION_TS.contains("session_before_compact"));
414        // Uses correct Pi import
415        assert!(MI6_EXTENSION_TS.contains("@mariozechner/pi-coding-agent"));
416        // Uses factory function pattern
417        assert!(MI6_EXTENSION_TS.contains("export default function"));
418        // Uses correct event handler signature (event, ctx)
419        assert!(MI6_EXTENSION_TS.contains("ExtensionContext"));
420    }
421
422    #[test]
423    fn test_generate_hooks_config() {
424        let adapter = PiAdapter;
425        let events = vec![EventType::SessionStart, EventType::PreToolUse];
426
427        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
428
429        // Should return a placeholder JSON since Pi uses TypeScript
430        assert!(config.get("_note").is_some());
431        assert!(config.get("extension_path").is_some());
432    }
433}