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