Skip to main content

sc/config/
status_cache.rs

1//! Status cache reader for session resolution.
2//!
3//! Reads the status cache written by the MCP server to determine the
4//! current session for this terminal. This provides a single source of
5//! truth for session state, eliminating guesswork.
6//!
7//! # TTY Resolution Strategy (matches MCP server)
8//!
9//! 1. `SAVECONTEXT_STATUS_KEY` env var (explicit override)
10//! 2. Parent process TTY via `ps -o tty= -p $PPID`
11//! 3. `TERM_SESSION_ID` env var (macOS Terminal.app)
12//! 4. `ITERM_SESSION_ID` env var (iTerm2)
13//! 5. None if no key available
14
15use serde::{Deserialize, Serialize};
16use std::fs;
17use std::io::Write;
18#[cfg(unix)]
19use std::os::unix::fs::OpenOptionsExt;
20use std::path::PathBuf;
21use std::process::Command;
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24/// Cache TTL: 2 hours (matches MCP server)
25const CACHE_TTL_MS: u64 = 2 * 60 * 60 * 1000;
26
27/// Status cache entry (matches MCP server format)
28#[derive(Debug, Deserialize, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct StatusCacheEntry {
31    pub session_id: String,
32    pub session_name: String,
33    pub project_path: String,
34    pub timestamp: u64,
35    pub provider: Option<String>,
36    pub item_count: Option<u32>,
37    pub session_status: Option<String>,
38}
39
40/// Get the status cache directory path.
41fn cache_dir() -> Option<PathBuf> {
42    directories::BaseDirs::new().map(|b| b.home_dir().join(".savecontext").join("status-cache"))
43}
44
45/// Sanitize a key for use as a filename.
46fn sanitize_key(key: &str) -> Option<String> {
47    let sanitized: String = key
48        .trim()
49        .chars()
50        .map(|c| {
51            if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
52               || c == '"' || c == '<' || c == '>' || c == '|' || c.is_whitespace() {
53                '_'
54            } else {
55                c
56            }
57        })
58        .take(100)
59        .collect();
60
61    if sanitized.is_empty() {
62        None
63    } else {
64        Some(sanitized)
65    }
66}
67
68/// Walk the process tree to find the controlling terminal.
69///
70/// Agent-spawned processes (e.g. Claude Code → shell → sc) often have
71/// no TTY ("??") on themselves and their immediate parent. The real
72/// terminal is held by the agent process further up the tree.
73/// Walk up to 5 ancestors to find it.
74fn find_tty_from_ancestors() -> Option<String> {
75    let mut current_pid = std::process::id().to_string();
76
77    for _ in 0..5 {
78        // Check this PID's TTY
79        if let Ok(output) = Command::new("ps")
80            .args(["-o", "tty=", "-p", &current_pid])
81            .output()
82        {
83            if output.status.success() {
84                let tty = String::from_utf8_lossy(&output.stdout).trim().to_string();
85                if !tty.is_empty() && tty != "?" && tty != "??" {
86                    return Some(tty);
87                }
88            }
89        }
90
91        // Walk to parent
92        let Ok(output) = Command::new("ps")
93            .args(["-o", "ppid=", "-p", &current_pid])
94            .output()
95        else {
96            break;
97        };
98
99        if !output.status.success() {
100            break;
101        }
102
103        let ppid = String::from_utf8_lossy(&output.stdout).trim().to_string();
104        if ppid.is_empty() || ppid == "0" || ppid == "1" || ppid == current_pid {
105            break;
106        }
107        current_pid = ppid;
108    }
109
110    None
111}
112
113/// Get the status key for this terminal.
114///
115/// Uses the same resolution strategy as the MCP server to ensure
116/// consistency between CLI and MCP session tracking.
117pub fn get_status_key() -> Option<String> {
118    // 1. Explicit override via environment variable
119    if let Ok(key) = std::env::var("SAVECONTEXT_STATUS_KEY") {
120        if !key.is_empty() {
121            return sanitize_key(&key);
122        }
123    }
124
125    // 2. Walk ancestor processes to find the controlling terminal
126    if let Some(tty) = find_tty_from_ancestors() {
127        return sanitize_key(&format!("tty-{}", tty));
128    }
129
130    // 3. macOS Terminal.app session ID
131    if let Ok(term_id) = std::env::var("TERM_SESSION_ID") {
132        if !term_id.is_empty() {
133            return sanitize_key(&format!("term-{}", term_id));
134        }
135    }
136
137    // 4. iTerm2 session ID
138    if let Ok(iterm_id) = std::env::var("ITERM_SESSION_ID") {
139        if !iterm_id.is_empty() {
140            return sanitize_key(&format!("iterm-{}", iterm_id));
141        }
142    }
143
144    // 5. No key available
145    None
146}
147
148/// Read the status cache entry for this terminal.
149///
150/// Returns `None` if:
151/// - No status key can be determined
152/// - Cache file doesn't exist
153/// - Cache entry is stale (older than 2 hours)
154/// - Cache file is corrupted
155pub fn read_status_cache() -> Option<StatusCacheEntry> {
156    let key = get_status_key()?;
157    let cache_path = cache_dir()?.join(format!("{}.json", key));
158
159    if !cache_path.exists() {
160        return None;
161    }
162
163    let content = fs::read_to_string(&cache_path).ok()?;
164    let entry: StatusCacheEntry = serde_json::from_str(&content).ok()?;
165
166    // Check TTL
167    let now = SystemTime::now()
168        .duration_since(UNIX_EPOCH)
169        .unwrap_or(Duration::ZERO)
170        .as_millis() as u64;
171
172    if now.saturating_sub(entry.timestamp) > CACHE_TTL_MS {
173        // Stale entry - try to remove it (ignore errors)
174        let _ = fs::remove_file(&cache_path);
175        return None;
176    }
177
178    Some(entry)
179}
180
181/// Get the current session ID from the status cache.
182///
183/// This is the primary function for CLI commands to determine which
184/// session they should operate on.
185pub fn current_session_id() -> Option<String> {
186    read_status_cache().map(|e| e.session_id)
187}
188
189/// Write a status cache entry for this terminal.
190///
191/// Uses the same atomic write pattern as the MCP server:
192/// write to temp file → rename to final path. This prevents
193/// partial reads from concurrent CLI/MCP access.
194///
195/// Returns `true` if the cache was written successfully.
196pub fn write_status_cache(entry: &StatusCacheEntry) -> bool {
197    let Some(key) = get_status_key() else {
198        return false;
199    };
200
201    let Some(dir) = cache_dir() else {
202        return false;
203    };
204
205    // Ensure cache directory exists
206    if let Err(_) = fs::create_dir_all(&dir) {
207        return false;
208    }
209
210    let file_path = dir.join(format!("{key}.json"));
211    let temp_path = dir.join(format!("{key}.json.tmp"));
212
213    // Serialize with pretty-print to match MCP server format
214    let Ok(json) = serde_json::to_string_pretty(entry) else {
215        return false;
216    };
217
218    // Write to temp file with restrictive permissions, then atomic rename
219    let result = (|| -> std::io::Result<()> {
220        {
221            let mut opts = fs::OpenOptions::new();
222            opts.write(true).create(true).truncate(true);
223            #[cfg(unix)]
224            opts.mode(0o600);
225            let mut file = opts.open(&temp_path)?;
226            file.write_all(json.as_bytes())?;
227            file.flush()?;
228        }
229        fs::rename(&temp_path, &file_path)?;
230        Ok(())
231    })();
232
233    result.is_ok()
234}
235
236/// Clear the status cache for this terminal.
237///
238/// Called when a session is paused or ended to unbind the
239/// terminal from that session.
240pub fn clear_status_cache() -> bool {
241    let Some(key) = get_status_key() else {
242        return false;
243    };
244
245    let Some(dir) = cache_dir() else {
246        return false;
247    };
248
249    let file_path = dir.join(format!("{key}.json"));
250
251    if file_path.exists() {
252        fs::remove_file(&file_path).is_ok()
253    } else {
254        true // Already clear
255    }
256}
257
258/// Build a `StatusCacheEntry` for a session and write it to the cache.
259///
260/// Convenience function that mirrors the MCP server's `updateStatusLine()`.
261/// Call this on session start/resume to bind the terminal to a session.
262pub fn bind_session_to_terminal(
263    session_id: &str,
264    session_name: &str,
265    project_path: &str,
266    status: &str,
267) -> bool {
268    let now = SystemTime::now()
269        .duration_since(UNIX_EPOCH)
270        .unwrap_or(Duration::ZERO)
271        .as_millis() as u64;
272
273    let entry = StatusCacheEntry {
274        session_id: session_id.to_string(),
275        session_name: session_name.to_string(),
276        project_path: project_path.to_string(),
277        timestamp: now,
278        provider: Some("cli".to_string()),
279        item_count: None,
280        session_status: Some(status.to_string()),
281    };
282
283    write_status_cache(&entry)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_sanitize_key() {
292        assert_eq!(sanitize_key("simple"), Some("simple".to_string()));
293        assert_eq!(sanitize_key("with/slash"), Some("with_slash".to_string()));
294        assert_eq!(sanitize_key("with spaces"), Some("with_spaces".to_string()));
295        assert_eq!(sanitize_key(""), None);
296        assert_eq!(sanitize_key("   "), None);
297    }
298
299    #[test]
300    fn test_cache_dir() {
301        let dir = cache_dir();
302        assert!(dir.is_some());
303        let path = dir.unwrap();
304        assert!(path.ends_with("status-cache"));
305    }
306}