Skip to main content

ccboard_core/
hook_state.rs

1//! Hook-based live session state
2//!
3//! Tracks Claude Code session status via hook events, written to
4//! ~/.ccboard/live-sessions.json with file locking for concurrent safety.
5
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12/// Composite key: `"{session_id}:{tty}"` — unique per session per terminal
13pub type SessionKey = String;
14
15/// Build a session key from session_id and tty
16pub fn make_session_key(session_id: &str, tty: &str) -> SessionKey {
17    format!("{}:{}", session_id, tty)
18}
19
20/// Status of a Claude Code session as observed via hooks
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum HookSessionStatus {
24    /// Claude is actively processing (PreToolUse, PostToolUse, UserPromptSubmit)
25    Running,
26    /// Waiting for user permission (Notification with permission_prompt)
27    WaitingInput,
28    /// Session has ended (Stop hook received)
29    Stopped,
30    /// Unknown status
31    #[default]
32    Unknown,
33}
34
35/// A Claude Code session tracked via hooks
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HookSession {
38    /// Claude session ID (from hook payload)
39    pub session_id: String,
40    /// Working directory of the Claude process
41    pub cwd: String,
42    /// TTY device path (e.g. "/dev/ttys001")
43    pub tty: String,
44    /// Current status
45    pub status: HookSessionStatus,
46    /// When this session was first seen
47    pub created_at: DateTime<Utc>,
48    /// When this session was last updated
49    pub updated_at: DateTime<Utc>,
50    /// Name of the last hook event received
51    pub last_event: String,
52}
53
54/// Contents of ~/.ccboard/live-sessions.json
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct LiveSessionFile {
57    /// Schema version — always 1 for now, used for future migrations
58    pub version: u8,
59    /// Active sessions keyed by "{session_id}:{tty}"
60    pub sessions: HashMap<SessionKey, HookSession>,
61    /// When this file was last written
62    pub updated_at: Option<DateTime<Utc>>,
63}
64
65/// Errors produced by hook state operations
66#[derive(Debug, Error)]
67pub enum HookStateError {
68    #[error("IO error: {0}")]
69    Io(#[from] std::io::Error),
70    #[error("JSON parse error: {0}")]
71    Json(#[from] serde_json::Error),
72    #[error("No home directory found")]
73    NoHome,
74}
75
76impl LiveSessionFile {
77    /// Default path: `~/.ccboard/live-sessions.json`
78    pub fn default_path() -> Option<PathBuf> {
79        dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.json"))
80    }
81
82    /// Lock file path: `~/.ccboard/live-sessions.lock`
83    pub fn lock_path() -> Option<PathBuf> {
84        dirs::home_dir().map(|h| h.join(".ccboard").join("live-sessions.lock"))
85    }
86
87    /// Load from disk. Returns `Default` if file does not exist; errors only on parse failure.
88    pub fn load(path: &Path) -> Result<Self, HookStateError> {
89        if !path.exists() {
90            return Ok(Self {
91                version: 1,
92                ..Default::default()
93            });
94        }
95        let data = std::fs::read(path)?;
96        let mut file: Self = serde_json::from_slice(&data)?;
97        // Ensure version is set on old files
98        if file.version == 0 {
99            file.version = 1;
100        }
101        Ok(file)
102    }
103
104    /// Atomic write: write to `.tmp`, then rename (APFS/ext4-safe)
105    pub fn save(&self, path: &Path) -> Result<(), HookStateError> {
106        let tmp_path = path.with_extension("tmp");
107        let data = serde_json::to_vec_pretty(self)?;
108        std::fs::write(&tmp_path, &data)?;
109        std::fs::rename(&tmp_path, path)?;
110        Ok(())
111    }
112
113    /// Remove `Stopped` sessions older than `max_age`
114    pub fn prune_stopped(&mut self, max_age: std::time::Duration) {
115        let cutoff =
116            Utc::now() - Duration::from_std(max_age).unwrap_or_else(|_| Duration::minutes(30));
117        self.sessions
118            .retain(|_, s| s.status != HookSessionStatus::Stopped || s.updated_at >= cutoff);
119    }
120
121    /// Remove `Running` / `WaitingInput` sessions not updated within `max_age`.
122    ///
123    /// Handles Claude processes that crashed or were killed without sending a Stop hook.
124    /// Default recommended value: 10 minutes.
125    pub fn prune_stale_running(&mut self, max_age: std::time::Duration) {
126        let cutoff =
127            Utc::now() - Duration::from_std(max_age).unwrap_or_else(|_| Duration::minutes(10));
128        self.sessions.retain(|_, s| {
129            // Stopped sessions are handled by prune_stopped — leave them alone here
130            s.status == HookSessionStatus::Stopped || s.updated_at >= cutoff
131        });
132    }
133
134    /// Upsert a session: create if new, update status/timestamp if existing.
135    /// Special rule: `UserPromptSubmit` on a `Stopped` session revives it to `Running`.
136    pub fn upsert(
137        &mut self,
138        key: SessionKey,
139        session_id: String,
140        cwd: String,
141        tty: String,
142        new_status: HookSessionStatus,
143        event_name: String,
144    ) {
145        let now = Utc::now();
146
147        // If the session was Stopped and we get a Running event → revive it
148        let effective_status = if new_status == HookSessionStatus::Running
149            && self
150                .sessions
151                .get(&key)
152                .map(|s| s.status == HookSessionStatus::Stopped)
153                .unwrap_or(false)
154        {
155            HookSessionStatus::Running
156        } else {
157            new_status
158        };
159
160        if let Some(existing) = self.sessions.get_mut(&key) {
161            existing.status = effective_status;
162            existing.updated_at = now;
163            existing.last_event = event_name;
164        } else {
165            self.sessions.insert(
166                key,
167                HookSession {
168                    session_id,
169                    cwd,
170                    tty,
171                    status: effective_status,
172                    created_at: now,
173                    updated_at: now,
174                    last_event: event_name,
175                },
176            );
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::time::Duration;
185
186    #[test]
187    fn test_prune_stopped_removes_old() {
188        let mut file = LiveSessionFile {
189            version: 1,
190            ..Default::default()
191        };
192
193        let old_time = Utc::now() - Duration::from_secs(31 * 60); // 31 minutes ago
194        file.sessions.insert(
195            "s1:tty1".to_string(),
196            HookSession {
197                session_id: "s1".to_string(),
198                cwd: "/tmp".to_string(),
199                tty: "tty1".to_string(),
200                status: HookSessionStatus::Stopped,
201                created_at: old_time,
202                updated_at: old_time,
203                last_event: "Stop".to_string(),
204            },
205        );
206
207        // Running session should survive
208        file.sessions.insert(
209            "s2:tty2".to_string(),
210            HookSession {
211                session_id: "s2".to_string(),
212                cwd: "/tmp".to_string(),
213                tty: "tty2".to_string(),
214                status: HookSessionStatus::Running,
215                created_at: old_time,
216                updated_at: old_time,
217                last_event: "PreToolUse".to_string(),
218            },
219        );
220
221        file.prune_stopped(Duration::from_secs(30 * 60));
222
223        assert!(!file.sessions.contains_key("s1:tty1"));
224        assert!(file.sessions.contains_key("s2:tty2"));
225    }
226
227    #[test]
228    fn test_upsert_revives_stopped() {
229        let mut file = LiveSessionFile {
230            version: 1,
231            ..Default::default()
232        };
233
234        let key = "s1:tty1".to_string();
235        let old_time = Utc::now() - chrono::Duration::seconds(5);
236
237        file.sessions.insert(
238            key.clone(),
239            HookSession {
240                session_id: "s1".to_string(),
241                cwd: "/tmp".to_string(),
242                tty: "tty1".to_string(),
243                status: HookSessionStatus::Stopped,
244                created_at: old_time,
245                updated_at: old_time,
246                last_event: "Stop".to_string(),
247            },
248        );
249
250        file.upsert(
251            key.clone(),
252            "s1".to_string(),
253            "/tmp".to_string(),
254            "tty1".to_string(),
255            HookSessionStatus::Running,
256            "UserPromptSubmit".to_string(),
257        );
258
259        assert_eq!(file.sessions[&key].status, HookSessionStatus::Running);
260    }
261
262    #[test]
263    fn test_prune_stale_running_removes_stale() {
264        let mut file = LiveSessionFile {
265            version: 1,
266            ..Default::default()
267        };
268
269        let stale_time = Utc::now() - Duration::from_secs(11 * 60);
270        let recent_time = Utc::now() - Duration::from_secs(60);
271
272        // Stale Running session — should be removed
273        file.sessions.insert(
274            "s1:tty1".to_string(),
275            HookSession {
276                session_id: "s1".to_string(),
277                cwd: "/tmp".to_string(),
278                tty: "tty1".to_string(),
279                status: HookSessionStatus::Running,
280                created_at: stale_time,
281                updated_at: stale_time,
282                last_event: "PreToolUse".to_string(),
283            },
284        );
285
286        // Stale WaitingInput session — should be removed
287        file.sessions.insert(
288            "s2:tty2".to_string(),
289            HookSession {
290                session_id: "s2".to_string(),
291                cwd: "/tmp".to_string(),
292                tty: "tty2".to_string(),
293                status: HookSessionStatus::WaitingInput,
294                created_at: stale_time,
295                updated_at: stale_time,
296                last_event: "Notification".to_string(),
297            },
298        );
299
300        // Recent Running session — should survive
301        file.sessions.insert(
302            "s3:tty3".to_string(),
303            HookSession {
304                session_id: "s3".to_string(),
305                cwd: "/tmp".to_string(),
306                tty: "tty3".to_string(),
307                status: HookSessionStatus::Running,
308                created_at: recent_time,
309                updated_at: recent_time,
310                last_event: "PreToolUse".to_string(),
311            },
312        );
313
314        // Old Stopped session — prune_stale_running should NOT touch it
315        file.sessions.insert(
316            "s4:tty4".to_string(),
317            HookSession {
318                session_id: "s4".to_string(),
319                cwd: "/tmp".to_string(),
320                tty: "tty4".to_string(),
321                status: HookSessionStatus::Stopped,
322                created_at: stale_time,
323                updated_at: stale_time,
324                last_event: "Stop".to_string(),
325            },
326        );
327
328        file.prune_stale_running(Duration::from_secs(10 * 60));
329
330        assert!(
331            !file.sessions.contains_key("s1:tty1"),
332            "stale Running should be pruned"
333        );
334        assert!(
335            !file.sessions.contains_key("s2:tty2"),
336            "stale WaitingInput should be pruned"
337        );
338        assert!(
339            file.sessions.contains_key("s3:tty3"),
340            "recent Running should survive"
341        );
342        assert!(
343            file.sessions.contains_key("s4:tty4"),
344            "Stopped handled by prune_stopped, not touched here"
345        );
346    }
347
348    #[test]
349    fn test_make_session_key() {
350        assert_eq!(
351            make_session_key("abc-123", "/dev/ttys001"),
352            "abc-123:/dev/ttys001"
353        );
354    }
355}