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    /// Upsert a session: create if new, update status/timestamp if existing.
122    /// Special rule: `UserPromptSubmit` on a `Stopped` session revives it to `Running`.
123    pub fn upsert(
124        &mut self,
125        key: SessionKey,
126        session_id: String,
127        cwd: String,
128        tty: String,
129        new_status: HookSessionStatus,
130        event_name: String,
131    ) {
132        let now = Utc::now();
133
134        // If the session was Stopped and we get a Running event → revive it
135        let effective_status = if new_status == HookSessionStatus::Running
136            && self
137                .sessions
138                .get(&key)
139                .map(|s| s.status == HookSessionStatus::Stopped)
140                .unwrap_or(false)
141        {
142            HookSessionStatus::Running
143        } else {
144            new_status
145        };
146
147        if let Some(existing) = self.sessions.get_mut(&key) {
148            existing.status = effective_status;
149            existing.updated_at = now;
150            existing.last_event = event_name;
151        } else {
152            self.sessions.insert(
153                key,
154                HookSession {
155                    session_id,
156                    cwd,
157                    tty,
158                    status: effective_status,
159                    created_at: now,
160                    updated_at: now,
161                    last_event: event_name,
162                },
163            );
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::time::Duration;
172
173    #[test]
174    fn test_prune_stopped_removes_old() {
175        let mut file = LiveSessionFile {
176            version: 1,
177            ..Default::default()
178        };
179
180        let old_time = Utc::now() - Duration::from_secs(31 * 60); // 31 minutes ago
181        file.sessions.insert(
182            "s1:tty1".to_string(),
183            HookSession {
184                session_id: "s1".to_string(),
185                cwd: "/tmp".to_string(),
186                tty: "tty1".to_string(),
187                status: HookSessionStatus::Stopped,
188                created_at: old_time,
189                updated_at: old_time,
190                last_event: "Stop".to_string(),
191            },
192        );
193
194        // Running session should survive
195        file.sessions.insert(
196            "s2:tty2".to_string(),
197            HookSession {
198                session_id: "s2".to_string(),
199                cwd: "/tmp".to_string(),
200                tty: "tty2".to_string(),
201                status: HookSessionStatus::Running,
202                created_at: old_time,
203                updated_at: old_time,
204                last_event: "PreToolUse".to_string(),
205            },
206        );
207
208        file.prune_stopped(Duration::from_secs(30 * 60));
209
210        assert!(!file.sessions.contains_key("s1:tty1"));
211        assert!(file.sessions.contains_key("s2:tty2"));
212    }
213
214    #[test]
215    fn test_upsert_revives_stopped() {
216        let mut file = LiveSessionFile {
217            version: 1,
218            ..Default::default()
219        };
220
221        let key = "s1:tty1".to_string();
222        let old_time = Utc::now() - chrono::Duration::seconds(5);
223
224        file.sessions.insert(
225            key.clone(),
226            HookSession {
227                session_id: "s1".to_string(),
228                cwd: "/tmp".to_string(),
229                tty: "tty1".to_string(),
230                status: HookSessionStatus::Stopped,
231                created_at: old_time,
232                updated_at: old_time,
233                last_event: "Stop".to_string(),
234            },
235        );
236
237        file.upsert(
238            key.clone(),
239            "s1".to_string(),
240            "/tmp".to_string(),
241            "tty1".to_string(),
242            HookSessionStatus::Running,
243            "UserPromptSubmit".to_string(),
244        );
245
246        assert_eq!(file.sessions[&key].status, HookSessionStatus::Running);
247    }
248
249    #[test]
250    fn test_make_session_key() {
251        assert_eq!(
252            make_session_key("abc-123", "/dev/ttys001"),
253            "abc-123:/dev/ttys001"
254        );
255    }
256}