Skip to main content

acp_cli/cli/
session.rs

1use std::path::Path;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::error::{AcpCliError, Result};
5use crate::session::history::load_history;
6use crate::session::persistence::SessionRecord;
7use crate::session::pid;
8use crate::session::scoping::{find_git_root, session_dir, session_key};
9
10/// Create a new session record and persist it to disk.
11pub fn sessions_new(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
12    let cwd_path = Path::new(cwd);
13    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
14    let dir_str = resolved_dir.to_string_lossy();
15    let session_name = name.unwrap_or("");
16    let key = session_key(agent, &dir_str, session_name);
17
18    let now = SystemTime::now()
19        .duration_since(UNIX_EPOCH)
20        .unwrap_or_default()
21        .as_secs();
22
23    let record = SessionRecord {
24        id: key.clone(),
25        agent: agent.to_string(),
26        cwd: resolved_dir.clone(),
27        name: name.map(|s| s.to_string()),
28        created_at: now,
29        closed: false,
30        acp_session_id: None,
31    };
32
33    let path = session_dir().join(format!("{key}.json"));
34    record.save(&path).map_err(AcpCliError::Io)?;
35
36    println!("Session created:");
37    println!("  id:    {key}");
38    println!("  agent: {agent}");
39    println!("  cwd:   {}", resolved_dir.display());
40    if let Some(n) = name {
41        println!("  name:  {n}");
42    }
43    println!("  file:  {}", path.display());
44
45    Ok(())
46}
47
48/// List all session files, optionally filtered by agent and cwd.
49pub fn sessions_list(agent: Option<&str>, cwd: Option<&str>) -> Result<()> {
50    let dir = session_dir();
51    if !dir.exists() {
52        println!("No sessions found.");
53        return Ok(());
54    }
55
56    let entries = std::fs::read_dir(&dir).map_err(AcpCliError::Io)?;
57    let mut found = false;
58
59    for entry in entries {
60        let entry = entry.map_err(AcpCliError::Io)?;
61        let path = entry.path();
62        if path.extension().and_then(|e| e.to_str()) != Some("json") {
63            continue;
64        }
65
66        let record = match SessionRecord::load(&path).map_err(AcpCliError::Io)? {
67            Some(r) => r,
68            None => continue,
69        };
70
71        // Filter by agent if specified
72        if let Some(a) = agent
73            && record.agent != a
74        {
75            continue;
76        }
77
78        // Filter by cwd if specified
79        if let Some(c) = cwd {
80            let cwd_path = Path::new(c);
81            let resolved = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
82            if record.cwd != resolved {
83                continue;
84            }
85        }
86
87        if !found {
88            println!("{:<12} {:<10} {:<6} CWD", "ID (short)", "AGENT", "STATUS");
89            println!("{}", "-".repeat(72));
90            found = true;
91        }
92
93        let short_id = &record.id[..12.min(record.id.len())];
94        let status = if record.closed { "closed" } else { "open" };
95        println!(
96            "{:<12} {:<10} {:<6} {}",
97            short_id,
98            record.agent,
99            status,
100            record.cwd.display()
101        );
102    }
103
104    if !found {
105        println!("No sessions found.");
106    }
107
108    Ok(())
109}
110
111/// Close a session by setting its `closed` flag to true.
112pub fn sessions_close(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
113    let cwd_path = Path::new(cwd);
114    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
115    let dir_str = resolved_dir.to_string_lossy();
116    let session_name = name.unwrap_or("");
117    let key = session_key(agent, &dir_str, session_name);
118
119    let path = session_dir().join(format!("{key}.json"));
120    let mut record = match SessionRecord::load(&path).map_err(AcpCliError::Io)? {
121        Some(r) => r,
122        None => {
123            return Err(AcpCliError::NoSession {
124                agent: agent.to_string(),
125                cwd: cwd.to_string(),
126            });
127        }
128    };
129
130    if record.closed {
131        println!("Session is already closed.");
132        return Ok(());
133    }
134
135    record.closed = true;
136    record.save(&path).map_err(AcpCliError::Io)?;
137
138    let short_id = &record.id[..12.min(record.id.len())];
139    println!("Session {short_id} closed.");
140    Ok(())
141}
142
143/// Show detailed information about a session.
144pub fn sessions_show(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
145    let cwd_path = Path::new(cwd);
146    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
147    let dir_str = resolved_dir.to_string_lossy();
148    let session_name = name.unwrap_or("");
149    let key = session_key(agent, &dir_str, session_name);
150
151    let path = session_dir().join(format!("{key}.json"));
152    let record = match SessionRecord::load(&path).map_err(AcpCliError::Io)? {
153        Some(r) => r,
154        None => {
155            return Err(AcpCliError::NoSession {
156                agent: agent.to_string(),
157                cwd: cwd.to_string(),
158            });
159        }
160    };
161
162    let status = if record.closed { "closed" } else { "open" };
163    let created = format_timestamp(record.created_at);
164
165    println!("ID:         {}", record.id);
166    println!("Agent:      {}", record.agent);
167    println!("CWD:        {}", record.cwd.display());
168    if let Some(ref n) = record.name {
169        println!("Name:       {n}");
170    }
171    println!("Created at: {created}");
172    println!("Status:     {status}");
173    if let Some(ref acp_id) = record.acp_session_id {
174        println!("ACP Session: {acp_id}");
175    }
176
177    Ok(())
178}
179
180/// Show conversation history for a session.
181pub fn sessions_history(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
182    let cwd_path = Path::new(cwd);
183    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
184    let dir_str = resolved_dir.to_string_lossy();
185    let session_name = name.unwrap_or("");
186    let key = session_key(agent, &dir_str, session_name);
187
188    // Verify the session exists
189    let sess_path = session_dir().join(format!("{key}.json"));
190    if SessionRecord::load(&sess_path)
191        .map_err(AcpCliError::Io)?
192        .is_none()
193    {
194        return Err(AcpCliError::NoSession {
195            agent: agent.to_string(),
196            cwd: cwd.to_string(),
197        });
198    }
199
200    let entries = load_history(&key).map_err(AcpCliError::Io)?;
201
202    if entries.is_empty() {
203        println!("No conversation history.");
204        return Ok(());
205    }
206
207    for entry in &entries {
208        let ts = format_timestamp(entry.timestamp);
209        println!("[{ts}] {}:", entry.role);
210        println!("{}", entry.content);
211        println!();
212    }
213
214    Ok(())
215}
216
217/// Cancel a running prompt by sending SIGTERM to the active process.
218pub fn cancel_prompt(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
219    let cwd_path = Path::new(cwd);
220    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
221    let dir_str = resolved_dir.to_string_lossy();
222    let session_name = name.unwrap_or("");
223    let key = session_key(agent, &dir_str, session_name);
224
225    match pid::read_pid(&key) {
226        Some(active_pid) => {
227            // Send SIGTERM to the running process
228            // SAFETY: sending SIGTERM to a known-alive PID is standard POSIX behavior.
229            let ret = unsafe { libc::kill(active_pid as libc::pid_t, libc::SIGTERM) };
230            if ret == 0 {
231                println!("Sent SIGTERM to process {active_pid}.");
232            } else {
233                eprintln!("Failed to send signal to process {active_pid}.");
234            }
235            Ok(())
236        }
237        None => {
238            println!("No active prompt.");
239            Ok(())
240        }
241    }
242}
243
244/// Show the status of the current session, including whether a prompt is running.
245pub fn session_status(agent: &str, cwd: &str, name: Option<&str>) -> Result<()> {
246    let cwd_path = Path::new(cwd);
247    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
248    let dir_str = resolved_dir.to_string_lossy();
249    let session_name = name.unwrap_or("");
250    let key = session_key(agent, &dir_str, session_name);
251
252    let sess_path = session_dir().join(format!("{key}.json"));
253    let record = match SessionRecord::load(&sess_path).map_err(AcpCliError::Io)? {
254        Some(r) => r,
255        None => {
256            return Err(AcpCliError::NoSession {
257                agent: agent.to_string(),
258                cwd: cwd.to_string(),
259            });
260        }
261    };
262
263    let active_pid = pid::read_pid(&key);
264    let status = if record.closed {
265        "closed"
266    } else if active_pid.is_some() {
267        "running"
268    } else {
269        "idle"
270    };
271
272    println!("Agent:   {}", record.agent);
273    println!("Session: {}", record.id);
274    println!("Status:  {status}");
275    if let Some(p) = active_pid {
276        println!("PID:     {p}");
277    }
278
279    Ok(())
280}
281
282/// Set the session mode by sending a request to the queue owner via IPC.
283///
284/// Requires an active session with a running queue owner process.
285pub async fn set_mode(agent: &str, cwd: &str, name: Option<&str>, mode: &str) -> Result<()> {
286    let cwd_path = Path::new(cwd);
287    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
288    let dir_str = resolved_dir.to_string_lossy();
289    let session_name = name.unwrap_or("");
290    let key = session_key(agent, &dir_str, session_name);
291
292    let mut client = crate::queue::client::QueueClient::connect(&key)
293        .await
294        .map_err(|_| AcpCliError::Connection("No active session. Start a prompt first.".into()))?;
295
296    client.set_mode(mode).await
297}
298
299/// Set a session config option by sending a request to the queue owner via IPC.
300///
301/// Requires an active session with a running queue owner process.
302pub async fn set_config(
303    agent: &str,
304    cwd: &str,
305    name: Option<&str>,
306    key: &str,
307    value: &str,
308) -> Result<()> {
309    let cwd_path = Path::new(cwd);
310    let resolved_dir = find_git_root(cwd_path).unwrap_or_else(|| cwd_path.to_path_buf());
311    let dir_str = resolved_dir.to_string_lossy();
312    let session_name = name.unwrap_or("");
313    let session_key = session_key(agent, &dir_str, session_name);
314
315    let mut client = crate::queue::client::QueueClient::connect(&session_key)
316        .await
317        .map_err(|_| AcpCliError::Connection("No active session. Start a prompt first.".into()))?;
318
319    client.set_config(key, value).await
320}
321
322/// Format a Unix timestamp as `YYYY-MM-DD HH:MM:SS`.
323fn format_timestamp(ts: u64) -> String {
324    let secs = ts;
325    // Simple UTC formatting without external crate
326    let days_since_epoch = secs / 86400;
327    let time_of_day = secs % 86400;
328    let hours = time_of_day / 3600;
329    let minutes = (time_of_day % 3600) / 60;
330    let seconds = time_of_day % 60;
331
332    // Civil date from days since 1970-01-01 (algorithm from Howard Hinnant)
333    let z = days_since_epoch as i64 + 719468;
334    let era = if z >= 0 { z } else { z - 146096 } / 146097;
335    let doe = (z - era * 146097) as u64;
336    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
337    let y = yoe as i64 + era * 400;
338    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
339    let mp = (5 * doy + 2) / 153;
340    let d = doy - (153 * mp + 2) / 5 + 1;
341    let m = if mp < 10 { mp + 3 } else { mp - 9 };
342    let y = if m <= 2 { y + 1 } else { y };
343
344    format!("{y:04}-{m:02}-{d:02} {hours:02}:{minutes:02}:{seconds:02}")
345}