Skip to main content

scud/commands/
attach.rs

1//! Attach command - Continue a headless session interactively
2//!
3//! This module provides functionality to resume a headless agent session
4//! by reading stored session metadata and launching the appropriate
5//! harness with session continuation flags.
6
7use anyhow::{Context, Result};
8use colored::Colorize;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12use crate::commands::spawn::terminal::{find_harness_binary, Harness};
13use crate::storage::Storage;
14
15/// Metadata for a headless session, stored for later continuation
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionMetadata {
18    /// Task ID this session is for
19    pub task_id: String,
20    /// Harness session ID (for --resume)
21    pub session_id: String,
22    /// Associated tag/phase
23    pub tag: String,
24    /// Process ID (if still running)
25    #[serde(default)]
26    pub pid: Option<u32>,
27    /// Harness used (claude, opencode)
28    #[serde(default = "default_harness")]
29    pub harness: String,
30}
31
32fn default_harness() -> String {
33    "claude".to_string()
34}
35
36impl SessionMetadata {
37    /// Create new session metadata
38    pub fn new(task_id: &str, session_id: &str, tag: &str, harness: &str) -> Self {
39        Self {
40            task_id: task_id.to_string(),
41            session_id: session_id.to_string(),
42            tag: tag.to_string(),
43            pid: None,
44            harness: harness.to_string(),
45        }
46    }
47
48    /// Set the process ID
49    pub fn with_pid(mut self, pid: u32) -> Self {
50        self.pid = Some(pid);
51        self
52    }
53}
54
55/// Get the directory for headless session metadata
56pub fn headless_metadata_dir(project_root: &Path) -> PathBuf {
57    project_root.join(".scud").join("headless")
58}
59
60/// Get the path to a session metadata file
61pub fn session_metadata_path(project_root: &Path, task_id: &str) -> PathBuf {
62    headless_metadata_dir(project_root).join(format!("{}.json", task_id))
63}
64
65/// Save session metadata for later continuation
66pub fn save_session_metadata(project_root: &Path, metadata: &SessionMetadata) -> Result<()> {
67    let metadata_dir = headless_metadata_dir(project_root);
68    std::fs::create_dir_all(&metadata_dir)
69        .context("Failed to create headless metadata directory")?;
70
71    let metadata_file = session_metadata_path(project_root, &metadata.task_id);
72    let content = serde_json::to_string_pretty(metadata)
73        .context("Failed to serialize session metadata")?;
74
75    std::fs::write(&metadata_file, content)
76        .context("Failed to write session metadata")?;
77
78    Ok(())
79}
80
81/// Load session metadata for a task
82pub fn load_session_metadata(project_root: &Path, task_id: &str) -> Result<SessionMetadata> {
83    let metadata_file = session_metadata_path(project_root, task_id);
84
85    if !metadata_file.exists() {
86        anyhow::bail!(
87            "No session metadata found for task '{}'. Was it run in headless mode?",
88            task_id
89        );
90    }
91
92    let content = std::fs::read_to_string(&metadata_file)
93        .context("Failed to read session metadata")?;
94
95    let metadata: SessionMetadata = serde_json::from_str(&content)
96        .context("Failed to parse session metadata")?;
97
98    Ok(metadata)
99}
100
101/// List all available headless sessions
102pub fn list_sessions(project_root: &Path) -> Result<Vec<SessionMetadata>> {
103    let metadata_dir = headless_metadata_dir(project_root);
104
105    if !metadata_dir.exists() {
106        return Ok(Vec::new());
107    }
108
109    let mut sessions = Vec::new();
110
111    for entry in std::fs::read_dir(&metadata_dir)? {
112        let entry = entry?;
113        let path = entry.path();
114
115        if path.extension().map(|e| e == "json").unwrap_or(false) {
116            if let Ok(content) = std::fs::read_to_string(&path) {
117                if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
118                    sessions.push(metadata);
119                }
120            }
121        }
122    }
123
124    Ok(sessions)
125}
126
127/// Delete session metadata after successful continuation
128pub fn delete_session_metadata(project_root: &Path, task_id: &str) -> Result<()> {
129    let metadata_file = session_metadata_path(project_root, task_id);
130
131    if metadata_file.exists() {
132        std::fs::remove_file(&metadata_file)
133            .context("Failed to delete session metadata")?;
134    }
135
136    Ok(())
137}
138
139/// Build the command arguments for interactive session continuation
140pub fn interactive_command(harness: Harness, session_id: &str) -> Result<Vec<String>> {
141    let binary_path = find_harness_binary(harness)?.to_string();
142
143    match harness {
144        Harness::Claude => Ok(vec![
145            binary_path,
146            "--resume".to_string(),
147            session_id.to_string(),
148        ]),
149        Harness::OpenCode => {
150            // OpenCode uses attach command with server URL and session
151            Ok(vec![
152                binary_path,
153                "attach".to_string(),
154                "http://localhost:4096".to_string(),
155                "--session".to_string(),
156                session_id.to_string(),
157            ])
158        }
159        Harness::Cursor => Ok(vec![
160            binary_path,
161            "--resume".to_string(),
162            session_id.to_string(),
163        ]),
164        #[cfg(feature = "direct-api")]
165        Harness::DirectApi => anyhow::bail!("Direct API sessions cannot be resumed interactively"),
166    }
167}
168
169/// Main entry point for the attach command
170pub fn run(
171    project_root: Option<PathBuf>,
172    task_id: &str,
173    harness_arg: Option<&str>,
174) -> Result<()> {
175    let storage = Storage::new(project_root.clone());
176    let root = storage.project_root().to_path_buf();
177
178    // Load session metadata
179    let metadata = load_session_metadata(&root, task_id)?;
180
181    // Determine harness - prefer stored harness, fall back to argument
182    let harness_str = harness_arg.unwrap_or(&metadata.harness);
183    let harness = Harness::parse(harness_str)?;
184
185    // Build interactive command
186    let cmd_args = interactive_command(harness, &metadata.session_id)?;
187
188    // Display info
189    println!("{}", "SCUD Attach".cyan().bold());
190    println!("{}", "═".repeat(50));
191    println!("{:<15} {}", "Task:".dimmed(), task_id.cyan());
192    println!("{:<15} {}", "Session:".dimmed(), metadata.session_id.dimmed());
193    println!("{:<15} {}", "Tag:".dimmed(), metadata.tag);
194    println!("{:<15} {}", "Harness:".dimmed(), harness.name().green());
195    if let Some(pid) = metadata.pid {
196        println!("{:<15} {}", "PID:".dimmed(), pid);
197    }
198    println!();
199    println!("{}", "Attaching to session...".cyan());
200    println!();
201
202    // Use exec to replace current process with interactive session
203    #[cfg(unix)]
204    {
205        use std::os::unix::process::CommandExt;
206
207        let err = std::process::Command::new(&cmd_args[0])
208            .args(&cmd_args[1..])
209            .exec();
210
211        // exec() only returns on error
212        anyhow::bail!("Failed to exec '{}': {}", cmd_args[0], err);
213    }
214
215    #[cfg(not(unix))]
216    {
217        // On non-Unix systems, spawn a child process and wait
218        let status = std::process::Command::new(&cmd_args[0])
219            .args(&cmd_args[1..])
220            .status()
221            .context("Failed to spawn interactive session")?;
222
223        if !status.success() {
224            anyhow::bail!("Interactive session exited with error");
225        }
226
227        Ok(())
228    }
229}
230
231/// List available headless sessions
232pub fn run_list(project_root: Option<PathBuf>) -> Result<()> {
233    let storage = Storage::new(project_root);
234    let root = storage.project_root().to_path_buf();
235
236    let sessions = list_sessions(&root)?;
237
238    if sessions.is_empty() {
239        println!("{}", "No headless sessions found.".dimmed());
240        println!(
241            "Run {} to start a headless session.",
242            "scud spawn --headless".cyan()
243        );
244        return Ok(());
245    }
246
247    println!("{}", "Headless Sessions:".cyan().bold());
248    println!();
249
250    for session in &sessions {
251        let pid_info = session
252            .pid
253            .map(|p| format!(" (pid: {})", p))
254            .unwrap_or_default();
255
256        println!(
257            "  {} {} [{}]{}",
258            session.task_id.cyan(),
259            session.tag.dimmed(),
260            session.harness.green(),
261            pid_info.dimmed()
262        );
263    }
264
265    println!();
266    println!(
267        "{}",
268        "Use 'scud attach <task_id>' to resume a session.".dimmed()
269    );
270
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use tempfile::TempDir;
278
279    #[test]
280    fn test_session_metadata_serialization() {
281        let metadata = SessionMetadata::new("1.1", "sess-abc123", "alpha", "claude")
282            .with_pid(12345);
283
284        let json = serde_json::to_string(&metadata).unwrap();
285        let parsed: SessionMetadata = serde_json::from_str(&json).unwrap();
286
287        assert_eq!(parsed.task_id, "1.1");
288        assert_eq!(parsed.session_id, "sess-abc123");
289        assert_eq!(parsed.tag, "alpha");
290        assert_eq!(parsed.harness, "claude");
291        assert_eq!(parsed.pid, Some(12345));
292    }
293
294    #[test]
295    fn test_save_and_load_session_metadata() {
296        let temp_dir = TempDir::new().unwrap();
297        let project_root = temp_dir.path();
298
299        let metadata = SessionMetadata::new("2.1", "sess-xyz789", "beta", "opencode");
300
301        // Save metadata
302        save_session_metadata(project_root, &metadata).unwrap();
303
304        // Verify file exists
305        let metadata_path = session_metadata_path(project_root, "2.1");
306        assert!(metadata_path.exists());
307
308        // Load metadata
309        let loaded = load_session_metadata(project_root, "2.1").unwrap();
310        assert_eq!(loaded.task_id, "2.1");
311        assert_eq!(loaded.session_id, "sess-xyz789");
312        assert_eq!(loaded.tag, "beta");
313        assert_eq!(loaded.harness, "opencode");
314    }
315
316    #[test]
317    fn test_load_nonexistent_session() {
318        let temp_dir = TempDir::new().unwrap();
319        let project_root = temp_dir.path();
320
321        let result = load_session_metadata(project_root, "nonexistent");
322        assert!(result.is_err());
323        assert!(result
324            .unwrap_err()
325            .to_string()
326            .contains("No session metadata found"));
327    }
328
329    #[test]
330    fn test_list_sessions() {
331        let temp_dir = TempDir::new().unwrap();
332        let project_root = temp_dir.path();
333
334        // Create multiple sessions
335        save_session_metadata(
336            project_root,
337            &SessionMetadata::new("1.1", "sess-1", "alpha", "claude"),
338        )
339        .unwrap();
340        save_session_metadata(
341            project_root,
342            &SessionMetadata::new("2.1", "sess-2", "beta", "opencode"),
343        )
344        .unwrap();
345
346        let sessions = list_sessions(project_root).unwrap();
347        assert_eq!(sessions.len(), 2);
348    }
349
350    #[test]
351    fn test_delete_session_metadata() {
352        let temp_dir = TempDir::new().unwrap();
353        let project_root = temp_dir.path();
354
355        let metadata = SessionMetadata::new("3.1", "sess-delete", "gamma", "claude");
356        save_session_metadata(project_root, &metadata).unwrap();
357
358        let metadata_path = session_metadata_path(project_root, "3.1");
359        assert!(metadata_path.exists());
360
361        delete_session_metadata(project_root, "3.1").unwrap();
362        assert!(!metadata_path.exists());
363    }
364
365    #[test]
366    fn test_default_harness() {
367        // Test that old metadata without harness field deserializes correctly
368        let json = r#"{"task_id": "1.1", "session_id": "sess-123", "tag": "test"}"#;
369        let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
370        assert_eq!(metadata.harness, "claude");
371    }
372
373    #[test]
374    fn test_interactive_command_claude() {
375        // This test will only pass if claude binary is found
376        // Skip if not available
377        if find_harness_binary(Harness::Claude).is_err() {
378            return;
379        }
380
381        let cmd = interactive_command(Harness::Claude, "sess-123").unwrap();
382        assert!(cmd.len() >= 3);
383        assert!(cmd[0].contains("claude"));
384        assert_eq!(cmd[1], "--resume");
385        assert_eq!(cmd[2], "sess-123");
386    }
387}