Skip to main content

csd/
session.rs

1//! Session metadata sidecar + path derivation.
2//!
3//! csd tracks each driven session with a small JSON sidecar under the state dir so `ps`/`state`
4//! can recover the session id, cwd and transcript path without re-deriving them. tmux remains the
5//! source of truth for liveness; the sidecar is the source of truth for identity.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15/// Persisted identity of one driven session.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Session {
18    /// tmux session name (also the sidecar filename stem).
19    pub name: String,
20    /// Pinned `--session-id` UUID; makes the transcript path deterministic.
21    pub session_id: String,
22    /// Working directory the agent was spawned in.
23    pub cwd: String,
24    /// Backend that drives this session (`claude`, later `codex`).
25    pub backend: String,
26    /// Path to the session transcript JSONL.
27    pub jsonl_path: PathBuf,
28    /// `--permission-mode` the session was spawned with, if any.
29    pub permission_mode: Option<String>,
30    /// Unix epoch seconds at spawn — used to correlate plan files (which live in a global dir).
31    pub created: u64,
32}
33
34impl Session {
35    /// Where this session's sidecar lives on disk. Validates `name` first so a hostile value
36    /// (path separators, `..`) can never escape the sessions directory on save/load/delete.
37    pub fn sidecar_path(name: &str) -> Result<PathBuf> {
38        validate_name(name)?;
39        Ok(sessions_dir()?.join(format!("{name}.json")))
40    }
41
42    /// Write the sidecar (creating the state dir if needed).
43    pub fn save(&self) -> Result<()> {
44        let path = Session::sidecar_path(&self.name)?; // validates the name
45        let dir = sessions_dir()?;
46        fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
47        let body = serde_json::to_string_pretty(self)?;
48        fs::write(&path, body).map_err(|e| Error::io(&path, e))
49    }
50
51    /// Load a sidecar by session name.
52    pub fn load(name: &str) -> Result<Session> {
53        let path = Session::sidecar_path(name)?;
54        let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
55            std::io::ErrorKind::NotFound => Error::NoSuchSession(name.to_string()),
56            _ => Error::io(&path, e),
57        })?;
58        Ok(serde_json::from_str(&body)?)
59    }
60
61    /// Remove the sidecar (best-effort; missing file is not an error).
62    pub fn delete(name: &str) -> Result<()> {
63        let path = Session::sidecar_path(name)?;
64        match fs::remove_file(&path) {
65            Ok(()) => Ok(()),
66            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
67            Err(e) => Err(Error::io(&path, e)),
68        }
69    }
70
71    /// All tracked sessions, sorted by name. Sidecars that fail to parse are skipped.
72    pub fn list() -> Result<Vec<Session>> {
73        let dir = sessions_dir()?;
74        if !dir.exists() {
75            return Ok(Vec::new());
76        }
77        let mut sessions = Vec::new();
78        for entry in fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
79            let entry = entry.map_err(|e| Error::io(&dir, e))?;
80            let path = entry.path();
81            if path.extension().and_then(|e| e.to_str()) != Some("json") {
82                continue;
83            }
84            if let Ok(body) = fs::read_to_string(&path) {
85                if let Ok(session) = serde_json::from_str::<Session>(&body) {
86                    sessions.push(session);
87                }
88            }
89        }
90        sessions.sort_by(|a, b| a.name.cmp(&b.name));
91        Ok(sessions)
92    }
93}
94
95/// Reject session names that aren't a single safe path segment. A name becomes both a tmux session
96/// name and a sidecar filename, so it must contain only `[A-Za-z0-9._-]`, start with an alphanumeric
97/// or underscore (no leading `-` that tmux could read as a flag), and never contain `..`.
98pub fn validate_name(name: &str) -> Result<()> {
99    let starts_ok = name
100        .chars()
101        .next()
102        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
103    let chars_ok = name
104        .chars()
105        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
106    if name.is_empty() || !starts_ok || !chars_ok || name.contains("..") {
107        return Err(Error::InvalidSessionName(name.to_string()));
108    }
109    Ok(())
110}
111
112/// `~/.local/state/csd/sessions`.
113fn sessions_dir() -> Result<PathBuf> {
114    let base = dirs::state_dir()
115        .or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
116        .ok_or(Error::NoDir { what: "state" })?;
117    Ok(base.join("csd").join("sessions"))
118}
119
120/// Current Unix epoch in seconds. (Clock-before-epoch is impossible in practice → 0 fallback.)
121pub fn now_epoch() -> u64 {
122    SystemTime::now()
123        .duration_since(UNIX_EPOCH)
124        .map(|d| d.as_secs())
125        .unwrap_or(0)
126}
127
128/// Slugify a cwd the way `claude` names its project transcript dir: every `/` → `-`.
129///
130/// `/tmp/claude-itest1` → `-tmp-claude-itest1` (PoC §2.1).
131pub fn cwd_slug(cwd: &str) -> String {
132    cwd.replace('/', "-")
133}
134
135/// Deterministic transcript path: `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`.
136pub fn jsonl_path(cwd: &str, session_id: &str) -> Result<PathBuf> {
137    let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
138    Ok(home
139        .join(".claude")
140        .join("projects")
141        .join(cwd_slug(cwd))
142        .join(format!("{session_id}.jsonl")))
143}
144
145/// `~/.claude/plans` — where `claude` writes plan files in plan mode (global, not per-session).
146pub fn plans_dir() -> Result<PathBuf> {
147    let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
148    Ok(home.join(".claude").join("plans"))
149}
150
151/// Modification time of `path` as Unix epoch seconds, if available.
152pub fn mtime_epoch(path: &Path) -> Option<u64> {
153    fs::metadata(path)
154        .and_then(|m| m.modified())
155        .ok()
156        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
157        .map(|d| d.as_secs())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn slug_replaces_every_slash() {
166        assert_eq!(cwd_slug("/tmp/claude-itest1"), "-tmp-claude-itest1");
167        assert_eq!(cwd_slug("/home/marshall/dev/csd"), "-home-marshall-dev-csd");
168    }
169
170    #[test]
171    fn accepts_normal_names() {
172        for name in ["csd-csd-abc123", "agent_1", "Foo.bar-2"] {
173            assert!(validate_name(name).is_ok(), "{name} should be valid");
174        }
175    }
176
177    #[test]
178    fn rejects_path_traversal_and_separators() {
179        for name in ["../x", "..", "a/b", "/etc/passwd", "-rf", "", "a..b", "foo/../bar"] {
180            assert!(validate_name(name).is_err(), "{name} should be rejected");
181        }
182    }
183}