Skip to main content

tmx/agents/
session_meta.rs

1//! Read Claude Code's own session-state file:
2//! `~/.claude/sessions/<PID>.json`. The file holds Claude's view of the
3//! UI status — `busy` (mid-turn), `idle` (at chat prompt),
4//! `waiting` (selection / permission dialog up) — keyed by the Claude
5//! process PID. That makes it a perfect primary signal for the
6//! dashboard: no pane scraping, no pattern matching, no session-UUID
7//! mapping.
8//!
9//! Caveats (per codex review):
10//!   * Undocumented surface — Claude could rename / restructure / drop
11//!     the file at any version. Treat as **best-effort telemetry**.
12//!   * Partial writes are possible — JSON parse failure on a fresh
13//!     mtime means we should retry rather than mis-classify.
14//!   * Stale entries can outlive the process. Caller already gates on a
15//!     live PID via sysinfo, but a check on `version`/`updatedAt` is
16//!     still worth surfacing if drift is observed.
17//!
18//! All failure paths return `None` so the dashboard stays alive and
19//! falls back to the pane-capture classifier.
20
21use std::fs;
22use std::path::PathBuf;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SessionStatus {
26    /// `busy` — Claude is mid-turn (running tools or composing).
27    Busy,
28    /// `idle` — Claude is parked at the chat input prompt.
29    Idle,
30    /// `waiting` — Claude is blocking on a permission / selection dialog.
31    Waiting,
32}
33
34#[derive(Debug, Clone)]
35pub struct SessionMeta {
36    pub status: SessionStatus,
37    pub session_id: String,
38    /// `updatedAt` epoch-millis. Recent = the status we read reflects the
39    /// current UI state. Hours old + `busy` = the previous Claude exited
40    /// mid-turn without rewriting the file (rare but observable).
41    pub updated_at_ms: i64,
42}
43
44#[derive(serde::Deserialize)]
45struct Raw {
46    #[serde(default)]
47    status: String,
48    #[serde(default, rename = "sessionId")]
49    session_id: String,
50    #[serde(default, rename = "updatedAt")]
51    updated_at: i64,
52}
53
54pub fn session_path(pid: u32) -> Option<PathBuf> {
55    Some(dirs::home_dir()?.join(format!(".claude/sessions/{pid}.json")))
56}
57
58/// Read + parse the per-PID session file. Returns `None` on any failure
59/// path (missing, unreadable, malformed JSON, unrecognised status) so
60/// the caller can fall back to another signal.
61pub fn read(pid: u32) -> Option<SessionMeta> {
62    let path = session_path(pid)?;
63    let bytes = fs::read(&path).ok()?;
64    parse(&bytes)
65}
66
67fn parse(bytes: &[u8]) -> Option<SessionMeta> {
68    let raw: Raw = serde_json::from_slice(bytes).ok()?;
69    let status = parse_status(&raw.status)?;
70    Some(SessionMeta {
71        status,
72        session_id: raw.session_id,
73        updated_at_ms: raw.updated_at,
74    })
75}
76
77fn parse_status(s: &str) -> Option<SessionStatus> {
78    match s {
79        "busy" => Some(SessionStatus::Busy),
80        "idle" => Some(SessionStatus::Idle),
81        "waiting" => Some(SessionStatus::Waiting),
82        _ => None,
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn parse_known_busy() {
92        let json =
93            br#"{"pid":1,"sessionId":"s-1","cwd":"/x","status":"busy","updatedAt":1700000000000}"#;
94        let m = parse(json).expect("parses");
95        assert_eq!(m.status, SessionStatus::Busy);
96        assert_eq!(m.session_id, "s-1");
97        assert_eq!(m.updated_at_ms, 1700000000000);
98    }
99
100    #[test]
101    fn parse_known_idle() {
102        let json = br#"{"status":"idle","sessionId":"sid"}"#;
103        let m = parse(json).expect("parses");
104        assert_eq!(m.status, SessionStatus::Idle);
105    }
106
107    #[test]
108    fn parse_known_waiting() {
109        let json = br#"{"status":"waiting"}"#;
110        let m = parse(json).expect("parses");
111        assert_eq!(m.status, SessionStatus::Waiting);
112    }
113
114    #[test]
115    fn parse_unknown_status_returns_none() {
116        // Future Claude versions might add new states; refuse to guess.
117        let json = br#"{"status":"thinking-hard"}"#;
118        assert!(parse(json).is_none());
119    }
120
121    #[test]
122    fn parse_malformed_json_returns_none() {
123        assert!(parse(b"not json").is_none());
124        assert!(parse(b"").is_none());
125        // Truncated mid-write — important since Claude rewrites the
126        // file atomically-ish but a stat-then-read race is possible.
127        assert!(parse(br#"{"status":"bu"#).is_none());
128    }
129
130    #[test]
131    fn parse_missing_status_field_returns_none() {
132        let json = br#"{"sessionId":"s-1"}"#;
133        assert!(parse(json).is_none());
134    }
135
136    #[test]
137    fn parse_tolerates_extra_fields() {
138        // Forward-compat: the real file has pid/cwd/startedAt/procStart/
139        // version/peerProtocol/kind/entrypoint we don't read.
140        let json = br#"{
141            "pid": 22650,
142            "sessionId": "31e571fa-2230-4854-908a-5d13e7a017b6",
143            "cwd": "/home/x/dev/tmx",
144            "startedAt": 1779149382456,
145            "procStart": "126214",
146            "version": "2.1.143",
147            "peerProtocol": 1,
148            "kind": "interactive",
149            "entrypoint": "cli",
150            "status": "busy",
151            "updatedAt": 1779165593130
152        }"#;
153        let m = parse(json).expect("parses real-shape file");
154        assert_eq!(m.status, SessionStatus::Busy);
155        assert_eq!(m.session_id, "31e571fa-2230-4854-908a-5d13e7a017b6");
156        assert_eq!(m.updated_at_ms, 1779165593130);
157    }
158
159    #[test]
160    fn session_path_uses_pid_filename() {
161        let p = session_path(42).expect("home dir present in test env");
162        assert!(p.ends_with(".claude/sessions/42.json"));
163    }
164}