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    /// Backend CLI version probed at spawn (the binary is pinned for the session's lifetime, so
31    /// `state`/`ps` can check marker drift without a per-poll subprocess). `None` when the probe
32    /// failed or the sidecar predates this field.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub backend_version: Option<String>,
35    /// Unix epoch seconds at spawn — used to correlate plan files (which live in a global dir).
36    pub created: u64,
37}
38
39impl Session {
40    /// Where this session's sidecar lives on disk. Validates `name` first so a hostile value
41    /// (path separators, `..`) can never escape the sessions directory on save/load/delete.
42    pub fn sidecar_path(name: &str) -> Result<PathBuf> {
43        validate_name(name)?;
44        Ok(sessions_dir()?.join(format!("{name}.json")))
45    }
46
47    /// Write the sidecar (creating the state dir if needed).
48    pub fn save(&self) -> Result<()> {
49        let path = Session::sidecar_path(&self.name)?; // validates the name
50        let dir = sessions_dir()?;
51        fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
52        let body = serde_json::to_string_pretty(self)?;
53        fs::write(&path, body).map_err(|e| Error::io(&path, e))
54    }
55
56    /// Load a sidecar by session name.
57    pub fn load(name: &str) -> Result<Session> {
58        let path = Session::sidecar_path(name)?;
59        let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
60            std::io::ErrorKind::NotFound => Error::NoSuchSession(name.to_string()),
61            _ => Error::io(&path, e),
62        })?;
63        Ok(serde_json::from_str(&body)?)
64    }
65
66    /// Remove the sidecar (best-effort; missing file is not an error).
67    pub fn delete(name: &str) -> Result<()> {
68        let path = Session::sidecar_path(name)?;
69        match fs::remove_file(&path) {
70            Ok(()) => Ok(()),
71            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
72            Err(e) => Err(Error::io(&path, e)),
73        }
74    }
75
76    /// All tracked sessions, sorted by name. Sidecars that fail to parse are skipped.
77    pub fn list() -> Result<Vec<Session>> {
78        let dir = sessions_dir()?;
79        if !dir.exists() {
80            return Ok(Vec::new());
81        }
82        let mut sessions = Vec::new();
83        for entry in fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
84            let entry = entry.map_err(|e| Error::io(&dir, e))?;
85            let path = entry.path();
86            if path.extension().and_then(|e| e.to_str()) != Some("json") {
87                continue;
88            }
89            if let Ok(body) = fs::read_to_string(&path) {
90                if let Ok(session) = serde_json::from_str::<Session>(&body) {
91                    sessions.push(session);
92                }
93            }
94        }
95        sessions.sort_by(|a, b| a.name.cmp(&b.name));
96        Ok(sessions)
97    }
98}
99
100/// Reject session names that aren't a single safe path segment. A name becomes both a tmux session
101/// name and a sidecar filename, so it must contain only `[A-Za-z0-9._-]`, start with an alphanumeric
102/// or underscore (no leading `-` that tmux could read as a flag), and never contain `..`.
103pub fn validate_name(name: &str) -> Result<()> {
104    let starts_ok = name
105        .chars()
106        .next()
107        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
108    let chars_ok = name
109        .chars()
110        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
111    if name.is_empty() || !starts_ok || !chars_ok || name.contains("..") {
112        return Err(Error::InvalidSessionName(name.to_string()));
113    }
114    Ok(())
115}
116
117/// `~/.local/state/csd/sessions`.
118fn sessions_dir() -> Result<PathBuf> {
119    let base = dirs::state_dir()
120        .or_else(|| dirs::home_dir().map(|h| h.join(".local/state")))
121        .ok_or(Error::NoDir { what: "state" })?;
122    Ok(base.join("csd").join("sessions"))
123}
124
125/// Current Unix epoch in seconds. (Clock-before-epoch is impossible in practice → 0 fallback.)
126pub fn now_epoch() -> u64 {
127    SystemTime::now()
128        .duration_since(UNIX_EPOCH)
129        .map(|d| d.as_secs())
130        .unwrap_or(0)
131}
132
133/// Slugify a cwd the way `claude` names its project transcript dir: every `/` → `-`.
134///
135/// `/tmp/claude-itest1` → `-tmp-claude-itest1` (PoC §2.1).
136pub fn cwd_slug(cwd: &str) -> String {
137    cwd.replace('/', "-")
138}
139
140/// Deterministic transcript path: `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`.
141pub fn jsonl_path(cwd: &str, session_id: &str) -> Result<PathBuf> {
142    let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
143    Ok(home
144        .join(".claude")
145        .join("projects")
146        .join(cwd_slug(cwd))
147        .join(format!("{session_id}.jsonl")))
148}
149
150/// `~/.claude/plans` — where `claude` writes plan files in plan mode (global, not per-session).
151pub fn plans_dir() -> Result<PathBuf> {
152    let home = dirs::home_dir().ok_or(Error::NoDir { what: "home" })?;
153    Ok(home.join(".claude").join("plans"))
154}
155
156/// Modification time of `path` as Unix epoch seconds, if available.
157pub fn mtime_epoch(path: &Path) -> Option<u64> {
158    fs::metadata(path)
159        .and_then(|m| m.modified())
160        .ok()
161        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
162        .map(|d| d.as_secs())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn slug_replaces_every_slash() {
171        assert_eq!(cwd_slug("/tmp/claude-itest1"), "-tmp-claude-itest1");
172        assert_eq!(cwd_slug("/home/marshall/dev/csd"), "-home-marshall-dev-csd");
173    }
174
175    #[test]
176    fn accepts_normal_names() {
177        for name in ["csd-csd-abc123", "agent_1", "Foo.bar-2"] {
178            assert!(validate_name(name).is_ok(), "{name} should be valid");
179        }
180    }
181
182    #[test]
183    fn sidecar_without_backend_version_loads_and_none_is_not_serialized() {
184        let old = r#"{
185            "name": "csd-x-abc",
186            "session_id": "00000000-0000-0000-0000-000000000000",
187            "cwd": "/tmp/x",
188            "backend": "claude",
189            "jsonl_path": "/tmp/x.jsonl",
190            "permission_mode": null,
191            "created": 1
192        }"#;
193        let session: Session = serde_json::from_str(old).unwrap();
194        assert_eq!(session.backend_version, None);
195        let body = serde_json::to_string(&session).unwrap();
196        assert!(!body.contains("backend_version"));
197    }
198
199    #[test]
200    fn rejects_path_traversal_and_separators() {
201        for name in ["../x", "..", "a/b", "/etc/passwd", "-rf", "", "a..b", "foo/../bar"] {
202            assert!(validate_name(name).is_err(), "{name} should be rejected");
203        }
204    }
205}