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