Skip to main content

tmx/agents/
state.rs

1//! Scan `~/.claude/state/` for markers that tell us what's blocked, what has
2//! an active intent, and which repos have a fresh review marker.
3//!
4//! Marker schemes mirror `~/.claude/hooks/_lib.sh::repo_hash` (first 12 chars
5//! of MD5 of the repo root path string). We deliberately re-implement the
6//! hash rather than shelling out to keep this read-only and fast.
7
8use std::collections::HashSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use md5::{Digest, Md5};
13
14/// Snapshot of the marker files we care about.
15#[derive(Debug, Default, Clone)]
16pub struct ClaudeStateMarkers {
17    /// All `reviewed-<repohash>` markers present.
18    pub reviewed_repo_hashes: HashSet<String>,
19    /// All `intent-active-<sid>-<repohash>.path` markers — keyed by repo hash.
20    pub intent_repo_hashes: HashSet<String>,
21    /// Total `stop-blocked-<sid>` markers (not pane-mappable without
22    /// inspecting open file descriptors — surfaced as a global counter).
23    pub blocked_count: usize,
24}
25
26pub fn state_dir() -> Option<PathBuf> {
27    dirs::home_dir().map(|h| h.join(".claude/state"))
28}
29
30pub fn scan() -> ClaudeStateMarkers {
31    let Some(dir) = state_dir() else {
32        return ClaudeStateMarkers::default();
33    };
34    scan_dir(&dir)
35}
36
37pub fn scan_dir(dir: &Path) -> ClaudeStateMarkers {
38    let Ok(read) = fs::read_dir(dir) else {
39        return ClaudeStateMarkers::default();
40    };
41    let mut markers = ClaudeStateMarkers::default();
42    for entry in read.flatten() {
43        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
44            continue;
45        };
46        classify(&name, &mut markers);
47    }
48    markers
49}
50
51fn classify(name: &str, markers: &mut ClaudeStateMarkers) {
52    if let Some(hash) = name.strip_prefix("reviewed-") {
53        // Markers come in 12-char and 40-char variants. Only the 12-char
54        // (`repo_hash`) form is what the active hooks write today, but we
55        // keep any hex-looking suffix as a candidate.
56        if is_hex(hash) {
57            markers.reviewed_repo_hashes.insert(hash.to_string());
58        }
59        return;
60    }
61    if let Some(rest) = name.strip_prefix("intent-active-")
62        && let Some(stem) = rest.strip_suffix(".path")
63    {
64        // Filename shape: intent-active-<SESSION_ID>-<REPO_HASH>.path
65        // SESSION_ID itself may contain dashes (e.g. "flow-test-1779031295"),
66        // so we anchor on the *last* dash-separated chunk being a hex hash.
67        if let Some((_, tail)) = stem.rsplit_once('-')
68            && is_hex(tail)
69        {
70            markers.intent_repo_hashes.insert(tail.to_string());
71        }
72        return;
73    }
74    if name.starts_with("stop-blocked-") {
75        markers.blocked_count += 1;
76    }
77}
78
79fn is_hex(s: &str) -> bool {
80    !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit())
81}
82
83/// Compute the same 12-char marker hash that `~/.claude/hooks/_lib.sh` writes.
84pub fn repo_hash(repo_root: &Path) -> String {
85    let s = repo_root.to_string_lossy();
86    let mut hasher = Md5::new();
87    hasher.update(s.as_bytes());
88    let digest = hasher.finalize();
89    let mut out = String::with_capacity(12);
90    for byte in digest.iter().take(6) {
91        out.push_str(&format!("{byte:02x}"));
92    }
93    out
94}
95
96/// Walk parents of `path` until a `.git` entry is found; returns that
97/// directory. `None` if not inside a git repo (or if I/O fails).
98pub fn find_repo_root(path: &Path) -> Option<PathBuf> {
99    let mut cur = if path.is_absolute() {
100        path.to_path_buf()
101    } else {
102        std::env::current_dir().ok()?.join(path)
103    };
104    loop {
105        if cur.join(".git").exists() {
106            return Some(cur);
107        }
108        cur = cur.parent()?.to_path_buf();
109    }
110}
111
112/// One row from a codex-companion workspace's `state.json::jobs[]`. The
113/// companion writes one workspace directory per repo, each with a single
114/// `state.json` carrying the full job history — we filter to the still-
115/// active ones so the dashboard isn't dominated by completed tasks.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CodexJob {
118    /// Stable per-job identifier (used as the cursor anchor key, so
119    /// navigation survives across refreshes).
120    pub id: String,
121    pub title: String,
122    pub kind_label: String,
123    /// Absolute workspace path the job was launched against.
124    pub workspace_root: PathBuf,
125    pub status: String,
126    pub started_at_ms: Option<i64>,
127    /// `updatedAt` epoch-millis. Used as a freshness signal: a `running`
128    /// job whose `updatedAt` is hours old is almost certainly a zombie
129    /// (codex-companion crashed without flipping the status).
130    pub updated_at_ms: Option<i64>,
131    /// PID the codex-companion recorded for this job. `None` for jobs
132    /// where the field was absent or null (typical for terminal states).
133    pub pid: Option<u32>,
134}
135
136impl CodexJob {
137    /// Repo basename for display purposes — falls back to the last segment
138    /// of the workspace root when present.
139    pub fn repo_name(&self) -> String {
140        self.workspace_root
141            .file_name()
142            .and_then(|n| n.to_str())
143            .unwrap_or("")
144            .to_string()
145    }
146
147    /// True if the job is still doing work — anything not terminal. The
148    /// codex-companion uses `completed` / `failed` / `cancelled` as
149    /// terminal states; we treat any other value (including `running`,
150    /// `queued`, missing, etc.) as active.
151    pub fn is_active(&self) -> bool {
152        !matches!(
153            self.status.as_str(),
154            "completed" | "failed" | "cancelled" | "canceled"
155        )
156    }
157}
158
159/// Heuristic: how long can a job sit in non-terminal status before we
160/// stop trusting the file? Real codex tasks finish in seconds-minutes;
161/// anything past an hour is almost certainly a zombie left by a
162/// codex-companion crash that never wrote the terminal status.
163pub const CODEX_JOB_MAX_AGE_MS: i64 = 3600 * 1000;
164
165#[derive(serde::Deserialize)]
166struct StateFile {
167    jobs: Option<Vec<RawJob>>,
168}
169
170#[derive(serde::Deserialize)]
171struct RawJob {
172    id: String,
173    #[serde(default)]
174    title: String,
175    #[serde(rename = "kindLabel", default)]
176    kind_label: String,
177    #[serde(rename = "workspaceRoot", default)]
178    workspace_root: String,
179    #[serde(default)]
180    status: String,
181    #[serde(rename = "startedAt", default)]
182    started_at: String,
183    #[serde(rename = "updatedAt", default)]
184    updated_at: String,
185    /// codex-companion writes either an integer or `null` here.
186    #[serde(default)]
187    pid: Option<u32>,
188}
189
190/// Enumerate currently-active codex-companion jobs across every workspace
191/// directory. `state.json` is the source of truth; directory mtime is no
192/// longer trusted as a job signal.
193pub fn codex_jobs() -> Vec<CodexJob> {
194    let Some(home) = dirs::home_dir() else {
195        return Vec::new();
196    };
197    let dir = home.join(".claude/state/codex-companion/state");
198    codex_jobs_in(&dir)
199}
200
201pub fn codex_jobs_in(dir: &Path) -> Vec<CodexJob> {
202    let Ok(read) = fs::read_dir(dir) else {
203        return Vec::new();
204    };
205    let mut jobs: Vec<CodexJob> = Vec::new();
206    for entry in read.flatten() {
207        let ok = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
208        if !ok {
209            continue;
210        }
211        jobs.extend(read_workspace_jobs(&entry.path()));
212    }
213    jobs.retain(CodexJob::is_active);
214    jobs.sort_by_key(|j| std::cmp::Reverse(j.started_at_ms));
215    jobs
216}
217
218fn read_workspace_jobs(workspace_dir: &Path) -> Vec<CodexJob> {
219    let state_path = workspace_dir.join("state.json");
220    let Ok(bytes) = fs::read(&state_path) else {
221        return Vec::new();
222    };
223    let Ok(file): Result<StateFile, _> = serde_json::from_slice(&bytes) else {
224        return Vec::new();
225    };
226    file.jobs
227        .unwrap_or_default()
228        .into_iter()
229        .map(|j| CodexJob {
230            id: j.id,
231            title: j.title,
232            kind_label: j.kind_label,
233            workspace_root: PathBuf::from(j.workspace_root),
234            status: j.status,
235            started_at_ms: parse_iso8601_millis(&j.started_at),
236            updated_at_ms: parse_iso8601_millis(&j.updated_at),
237            pid: j.pid,
238        })
239        .collect()
240}
241
242/// Minimal ISO-8601 → epoch-millis parser tuned for codex-companion's
243/// `YYYY-MM-DDTHH:MM:SS.fffZ` output. Anything more exotic returns None
244/// and the row simply loses its age string.
245fn parse_iso8601_millis(s: &str) -> Option<i64> {
246    if s.is_empty() {
247        return None;
248    }
249    // Format: 2026-05-19T01:56:52.454Z
250    let bytes = s.as_bytes();
251    if bytes.len() < 20 {
252        return None;
253    }
254    let year: i64 = s.get(0..4)?.parse().ok()?;
255    let month: u32 = s.get(5..7)?.parse().ok()?;
256    let day: u32 = s.get(8..10)?.parse().ok()?;
257    let hour: i64 = s.get(11..13)?.parse().ok()?;
258    let min: i64 = s.get(14..16)?.parse().ok()?;
259    let sec: i64 = s.get(17..19)?.parse().ok()?;
260    let mut millis: i64 = 0;
261    if let Some(dot) = s.get(19..20)
262        && dot == "."
263    {
264        let frac = s.get(20..23)?;
265        millis = frac.parse().ok()?;
266    }
267    let days = days_from_civil(year, month, day);
268    let epoch_days = days - 719_468; // days(1970-01-01)
269    let secs = epoch_days * 86_400 + hour * 3600 + min * 60 + sec;
270    Some(secs * 1000 + millis)
271}
272
273/// Howard Hinnant's days_from_civil — fast, exact, no allocations.
274fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
275    let y = if m <= 2 { y - 1 } else { y };
276    let era = y.div_euclid(400);
277    let yoe = (y - era * 400) as u32; // [0, 399]
278    let m_adj = if m > 2 { m - 3 } else { m + 9 };
279    let doy = (153 * m_adj + 2) / 5 + d - 1; // [0, 365]
280    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
281    era * 146_097 + doe as i64
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::fs::File;
288    use tempfile::tempdir;
289
290    #[test]
291    fn classify_reviewed_marker() {
292        let mut m = ClaudeStateMarkers::default();
293        classify("reviewed-0cc68405c025", &mut m);
294        assert!(m.reviewed_repo_hashes.contains("0cc68405c025"));
295    }
296
297    #[test]
298    fn classify_reviewed_longer_hash_still_hex() {
299        let mut m = ClaudeStateMarkers::default();
300        classify("reviewed-6e6c4ed60ca111a1ec98145c09ff9d9426b78f54", &mut m);
301        assert_eq!(m.reviewed_repo_hashes.len(), 1);
302    }
303
304    #[test]
305    fn classify_reviewed_rejects_non_hex() {
306        let mut m = ClaudeStateMarkers::default();
307        classify("reviewed-not_a_hash", &mut m);
308        assert!(m.reviewed_repo_hashes.is_empty());
309    }
310
311    #[test]
312    fn classify_intent_active_extracts_trailing_hash() {
313        let mut m = ClaudeStateMarkers::default();
314        classify(
315            "intent-active-flow-test-1779031295-b2c6a07f1836.path",
316            &mut m,
317        );
318        assert!(m.intent_repo_hashes.contains("b2c6a07f1836"));
319    }
320
321    #[test]
322    fn classify_blocked_increments_count() {
323        let mut m = ClaudeStateMarkers::default();
324        classify("stop-blocked-abc123", &mut m);
325        classify("stop-blocked-xyz789", &mut m);
326        assert_eq!(m.blocked_count, 2);
327    }
328
329    #[test]
330    fn classify_ignores_unrelated() {
331        let mut m = ClaudeStateMarkers::default();
332        classify("dirty-some-uuid.log", &mut m);
333        classify("auto-review-disabled", &mut m);
334        assert!(m.reviewed_repo_hashes.is_empty());
335        assert!(m.intent_repo_hashes.is_empty());
336        assert_eq!(m.blocked_count, 0);
337    }
338
339    #[test]
340    fn repo_hash_stable() {
341        let h1 = repo_hash(Path::new("/home/me/dev/tmx"));
342        let h2 = repo_hash(Path::new("/home/me/dev/tmx"));
343        assert_eq!(h1, h2);
344        assert_eq!(h1.len(), 12);
345        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
346    }
347
348    #[test]
349    fn repo_hash_differs_by_path() {
350        let h1 = repo_hash(Path::new("/home/me/dev/tmx"));
351        let h2 = repo_hash(Path::new("/home/me/dev/nestty"));
352        assert_ne!(h1, h2);
353    }
354
355    #[test]
356    fn scan_dir_collects_all_marker_kinds() {
357        let tmp = tempdir().unwrap();
358        File::create(tmp.path().join("reviewed-deadbeef0123")).unwrap();
359        File::create(tmp.path().join("intent-active-sid-uuid-cafef00d1234.path")).unwrap();
360        File::create(tmp.path().join("stop-blocked-some-session")).unwrap();
361        File::create(tmp.path().join("dirty-some-session.log")).unwrap();
362
363        let markers = scan_dir(tmp.path());
364        assert!(markers.reviewed_repo_hashes.contains("deadbeef0123"));
365        assert!(markers.intent_repo_hashes.contains("cafef00d1234"));
366        assert_eq!(markers.blocked_count, 1);
367    }
368
369    #[test]
370    fn scan_dir_missing_dir_is_empty() {
371        let markers = scan_dir(Path::new("/nonexistent/path/xyz/123"));
372        assert!(markers.reviewed_repo_hashes.is_empty());
373        assert_eq!(markers.blocked_count, 0);
374    }
375
376    #[test]
377    fn find_repo_root_walks_up_to_git_dir() {
378        let tmp = tempdir().unwrap();
379        let nested = tmp.path().join("a/b/c");
380        fs::create_dir_all(&nested).unwrap();
381        fs::create_dir(tmp.path().join(".git")).unwrap();
382        let root = find_repo_root(&nested).unwrap();
383        assert_eq!(
384            root.canonicalize().unwrap(),
385            tmp.path().canonicalize().unwrap()
386        );
387    }
388
389    #[test]
390    fn codex_job_repo_name_uses_workspace_basename() {
391        let j = CodexJob {
392            id: "task-x".into(),
393            title: "Codex Task".into(),
394            kind_label: "rescue".into(),
395            workspace_root: PathBuf::from("/home/me/dev/tmx"),
396            status: "running".into(),
397            started_at_ms: Some(0),
398            updated_at_ms: Some(0),
399            pid: None,
400        };
401        assert_eq!(j.repo_name(), "tmx");
402    }
403
404    #[test]
405    fn codex_job_is_active_excludes_terminal_states() {
406        let base = CodexJob {
407            id: "task-x".into(),
408            title: String::new(),
409            kind_label: String::new(),
410            workspace_root: PathBuf::new(),
411            status: "running".into(),
412            started_at_ms: None,
413            updated_at_ms: None,
414            pid: None,
415        };
416        assert!(base.is_active());
417        for s in ["completed", "failed", "cancelled", "canceled"] {
418            assert!(
419                !CodexJob {
420                    status: s.into(),
421                    ..base.clone()
422                }
423                .is_active(),
424                "{s} should be terminal"
425            );
426        }
427    }
428
429    #[test]
430    fn codex_jobs_in_filters_to_active_and_parses_state_json() {
431        let tmp = tempdir().unwrap();
432        let ws = tmp.path().join("tmx-abcd1234");
433        fs::create_dir_all(&ws).unwrap();
434        let state = r#"{
435            "version": 1,
436            "jobs": [
437                {"id": "task-running", "title": "Live", "kindLabel": "rescue",
438                 "workspaceRoot": "/home/me/dev/tmx", "status": "running",
439                 "startedAt": "2026-05-19T01:00:00.000Z",
440                 "updatedAt": "2026-05-19T01:01:00.000Z",
441                 "pid": 12345},
442                {"id": "task-done", "title": "Old", "kindLabel": "rescue",
443                 "workspaceRoot": "/home/me/dev/tmx", "status": "completed",
444                 "startedAt": "2026-05-18T01:00:00.000Z",
445                 "updatedAt": "2026-05-18T01:00:01.000Z",
446                 "pid": null}
447            ]
448        }"#;
449        fs::write(ws.join("state.json"), state).unwrap();
450
451        let jobs = codex_jobs_in(tmp.path());
452        assert_eq!(jobs.len(), 1);
453        assert_eq!(jobs[0].id, "task-running");
454        assert_eq!(jobs[0].status, "running");
455        assert_eq!(jobs[0].pid, Some(12345));
456        assert!(jobs[0].updated_at_ms.is_some());
457    }
458
459    #[test]
460    fn codex_jobs_in_skips_missing_state_json() {
461        let tmp = tempdir().unwrap();
462        fs::create_dir_all(tmp.path().join("empty-workspace")).unwrap();
463        let jobs = codex_jobs_in(tmp.path());
464        assert!(jobs.is_empty());
465    }
466
467    #[test]
468    fn parse_iso8601_known_value() {
469        // 2026-05-19T01:56:52.454Z
470        let ms = parse_iso8601_millis("2026-05-19T01:56:52.454Z").unwrap();
471        // Sanity-check by round-tripping through a separate civil computation:
472        // 2026-05-19 == days_from_civil(2026, 5, 19) - 719_468.
473        let days = days_from_civil(2026, 5, 19) - 719_468;
474        let secs = days * 86_400 + 3600 + 56 * 60 + 52;
475        assert_eq!(ms, secs * 1000 + 454);
476    }
477
478    #[test]
479    fn parse_iso8601_rejects_garbage() {
480        assert!(parse_iso8601_millis("").is_none());
481        assert!(parse_iso8601_millis("not a date").is_none());
482        assert!(parse_iso8601_millis("2026-13-99T99:99:99.999Z").is_some()); // lenient on out-of-range, just needs to parse
483    }
484}