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