Skip to main content

claude_wrapper/
jobs.rs

1//! Read-side access to Claude Code's on-disk **background-job** state.
2//!
3//! Claude Code 2.1.x ships a supervisor daemon (`claude daemon run`)
4//! that orchestrates background agent tasks launched via the
5//! `claude agents` TUI. Per-task state lives under
6//! `~/.claude/jobs/<short-id>/`:
7//!
8//! - `state.json` -- current snapshot: state, intent (original
9//!   prompt), session id, link to the session JSONL, timestamps,
10//!   auto-generated name, etc.
11//! - `timeline.jsonl` -- append-only event log: at each state
12//!   transition, the daemon writes a line carrying timestamp,
13//!   new state, one-line detail, and (often) the full text body.
14//!
15//! The session content itself is a normal
16//! `~/.claude/projects/<slug>/<session_id>.jsonl` -- the same
17//! format [`crate::history`] already parses. Each job's
18//! [`JobSummary::session_path`] points at it for direct cross-linking.
19//!
20//! This module is read-only on purpose. The dispatch protocol (how
21//! the TUI launches new tasks) is undocumented and version-sensitive;
22//! mirroring it would defeat the drift defenses we built. Hosts that
23//! want to fire background work should keep using the agents TUI or
24//! the wrapper's [`crate::duplex::DuplexSession`] machinery.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use claude_wrapper::jobs::JobsRoot;
30//!
31//! # fn example() -> claude_wrapper::Result<()> {
32//! let root = JobsRoot::home()?;
33//! for s in root.list()? {
34//!     println!("{}  [{}]  {}", s.short_id, s.state, s.intent.as_deref().unwrap_or(""));
35//! }
36//! // Drill into one job's full timeline:
37//! let job = root.get("90c961c7")?;
38//! for event in &job.timeline {
39//!     println!("{}  {:?}", event.at.as_deref().unwrap_or("?"), event.state);
40//! }
41//! # Ok(()) }
42//! ```
43
44use std::fs;
45use std::io::{BufRead, BufReader};
46use std::path::{Path, PathBuf};
47use std::time::SystemTime;
48
49use serde::Serialize;
50use serde_json::Value;
51
52use crate::error::{Error, Result};
53
54/// Root directory of Claude Code's on-disk background-job state.
55/// Defaults to `~/.claude/jobs`; override with [`JobsRoot::at`] for
56/// tests or non-default installs.
57#[derive(Debug, Clone)]
58pub struct JobsRoot {
59    path: PathBuf,
60}
61
62impl JobsRoot {
63    /// Resolve the default `~/.claude/jobs`. Errors if the user
64    /// home directory cannot be determined.
65    pub fn home() -> Result<Self> {
66        let home = home_dir().ok_or_else(|| Error::Artifacts {
67            message: "could not determine user home directory".to_string(),
68        })?;
69        Ok(Self {
70            path: home.join(".claude").join("jobs"),
71        })
72    }
73
74    /// Use a specific path as the jobs root. Useful for tests
75    /// (point at a tempdir) and for non-default installs.
76    pub fn at(path: impl Into<PathBuf>) -> Self {
77        Self { path: path.into() }
78    }
79
80    /// The configured root directory.
81    pub fn path(&self) -> &Path {
82        &self.path
83    }
84
85    /// List every job directory at the root, sorted by `short_id`.
86    ///
87    /// Returns an empty vec if the root directory doesn't exist (no
88    /// background agents have been launched yet on this machine).
89    /// Skips entries that aren't directories, that don't carry a
90    /// `state.json`, or that fail to parse -- those contribute a
91    /// tracing warning so silent skips are diagnosable.
92    pub fn list(&self) -> Result<Vec<JobSummary>> {
93        let entries = match fs::read_dir(&self.path) {
94            Ok(it) => it,
95            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
96            Err(e) => return Err(e.into()),
97        };
98
99        let mut out = Vec::new();
100        for entry in entries.flatten() {
101            let ft = match entry.file_type() {
102                Ok(ft) => ft,
103                Err(_) => continue,
104            };
105            if !ft.is_dir() {
106                // Skip pins.json and any other top-level files.
107                continue;
108            }
109            let short_id = entry.file_name().to_string_lossy().into_owned();
110            let state_path = entry.path().join("state.json");
111            if !state_path.exists() {
112                // A spare worker dir without a task; skip silently.
113                continue;
114            }
115            match parse_state(&state_path, &short_id) {
116                Ok(summary) => out.push(summary),
117                Err(e) => tracing::warn!(?state_path, "skipping job: {e}"),
118            }
119        }
120        out.sort_by(|a, b| a.short_id.cmp(&b.short_id));
121        Ok(out)
122    }
123
124    /// Read one job by short id (its `~/.claude/jobs/<short_id>/`
125    /// directory name). Returns the full record including the
126    /// parsed `timeline.jsonl`. Errors if no such directory exists
127    /// or `state.json` is missing / malformed.
128    pub fn get(&self, short_id: &str) -> Result<Job> {
129        let dir = self.path.join(short_id);
130        let state_path = dir.join("state.json");
131        if !state_path.exists() {
132            return Err(Error::Artifacts {
133                message: format!("no job at {}", dir.display()),
134            });
135        }
136        let summary = parse_state(&state_path, short_id)?;
137        let timeline = parse_timeline(&dir.join("timeline.jsonl"));
138        let raw_state =
139            serde_json::from_str(&fs::read_to_string(&state_path)?).unwrap_or(Value::Null);
140        Ok(Job {
141            summary,
142            timeline,
143            raw_state,
144        })
145    }
146}
147
148/// Cheap metadata view of one background job, returned by
149/// [`JobsRoot::list`]. Stripped of the timeline.
150#[derive(Debug, Clone, Serialize)]
151pub struct JobSummary {
152    /// On-disk directory name (e.g. `"90c961c7"`). Canonical
153    /// handle for [`JobsRoot::get`].
154    pub short_id: String,
155    /// Lifecycle state as reported by the daemon
156    /// (`"running" | "done" | "killed" | "failed" | ...`).
157    pub state: String,
158    /// Daemon-assigned short id (typically matches `short_id` from
159    /// the directory name, but kept separately because the daemon
160    /// could in principle reorganize the directory layout).
161    pub daemon_short: Option<String>,
162    /// Backend kind (`"daemon"` for normal background agents). Free
163    /// text -- expose as-is for forward-compat with future backends.
164    pub backend: Option<String>,
165    /// Auto-generated short title shown in the agents TUI
166    /// (e.g. `"crow diet research"`). Optional; absent on freshly
167    /// created jobs before the daemon names them.
168    pub name: Option<String>,
169    /// One-line summary the daemon writes at each state transition
170    /// (often the result for terminal states).
171    pub detail: Option<String>,
172    /// Original prompt the user submitted (`"lets research the
173    /// typical diet of crows"`). The most useful field for human
174    /// scanning; absent only on weirdly malformed records.
175    pub intent: Option<String>,
176    /// Full Claude session ID. Used to look up the conversation
177    /// JSONL via [`crate::history::HistoryRoot::read_session`].
178    pub session_id: Option<String>,
179    /// Absolute path to the session JSONL (`linkScanPath` in the
180    /// raw record). Same file `claude_wrapper::history` parses.
181    pub session_path: Option<PathBuf>,
182    /// Working directory the agent ran in.
183    pub cwd: Option<PathBuf>,
184    /// Where the agent was originally dispatched from (may differ
185    /// from `cwd` after the agent navigated).
186    pub origin_cwd: Option<PathBuf>,
187    /// ISO-8601 timestamp the job was created.
188    pub created_at: Option<String>,
189    /// ISO-8601 timestamp of the most recent state update.
190    pub updated_at: Option<String>,
191    /// ISO-8601 timestamp the job first reached a terminal state.
192    /// `None` for still-running jobs.
193    pub first_terminal_at: Option<String>,
194    /// CLI version the worker reported running. Useful when
195    /// debugging cross-version state issues.
196    pub cli_version: Option<String>,
197    /// Last filesystem modification time of `state.json`, as
198    /// Unix-epoch seconds. Cheap fallback for sorting when
199    /// `updated_at` is missing.
200    pub state_mtime_secs: Option<u64>,
201}
202
203/// Full job record returned by [`JobsRoot::get`]. Carries the
204/// summary, the parsed timeline, and the raw `state.json` value
205/// for callers that want to drill into fields this module doesn't
206/// type explicitly.
207#[derive(Debug, Clone, Serialize)]
208pub struct Job {
209    /// Same shape as [`JobsRoot::list`] returns.
210    pub summary: JobSummary,
211    /// One entry per line in `timeline.jsonl`, in file order.
212    pub timeline: Vec<JobEvent>,
213    /// Verbatim parsed `state.json`. Use this to access fields not
214    /// covered by [`JobSummary`] (e.g. `inFlight.tasks`,
215    /// `respawnFlags`, `tempo`).
216    pub raw_state: Value,
217}
218
219/// One timeline event. All fields optional because the daemon may
220/// emit partial events (e.g. without `text`) and we'd rather pass
221/// the structure through than fail the load.
222#[derive(Debug, Clone, Serialize)]
223pub struct JobEvent {
224    /// ISO-8601 timestamp.
225    pub at: Option<String>,
226    /// State name at this point in the timeline.
227    pub state: Option<String>,
228    /// One-line detail (often the final result for terminal events).
229    pub detail: Option<String>,
230    /// Full text body (markdown, often quite long for `done`
231    /// events). Distinct from `detail`: `detail` is a one-liner,
232    /// `text` is the full content.
233    pub text: Option<String>,
234    /// Anything the daemon emits that doesn't fit the above. Lets
235    /// future daemon fields show up without a wrapper update.
236    pub extra: Value,
237}
238
239fn parse_state(path: &Path, short_id: &str) -> Result<JobSummary> {
240    let raw = fs::read_to_string(path)?;
241    let v: Value = serde_json::from_str(&raw).map_err(|e| Error::Artifacts {
242        message: format!("parse {}: {e}", path.display()),
243    })?;
244    let state_mtime_secs = fs::metadata(path)
245        .and_then(|m| m.modified())
246        .ok()
247        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
248        .map(|d| d.as_secs());
249    Ok(JobSummary {
250        short_id: short_id.to_string(),
251        state: v
252            .get("state")
253            .and_then(Value::as_str)
254            .unwrap_or("unknown")
255            .to_string(),
256        daemon_short: v
257            .get("daemonShort")
258            .and_then(Value::as_str)
259            .map(str::to_string),
260        backend: v.get("backend").and_then(Value::as_str).map(str::to_string),
261        name: v.get("name").and_then(Value::as_str).map(str::to_string),
262        detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
263        intent: v.get("intent").and_then(Value::as_str).map(str::to_string),
264        session_id: v
265            .get("sessionId")
266            .and_then(Value::as_str)
267            .map(str::to_string),
268        session_path: v
269            .get("linkScanPath")
270            .and_then(Value::as_str)
271            .map(PathBuf::from),
272        cwd: v.get("cwd").and_then(Value::as_str).map(PathBuf::from),
273        origin_cwd: v
274            .get("originCwd")
275            .and_then(Value::as_str)
276            .map(PathBuf::from),
277        created_at: v
278            .get("createdAt")
279            .and_then(Value::as_str)
280            .map(str::to_string),
281        updated_at: v
282            .get("updatedAt")
283            .and_then(Value::as_str)
284            .map(str::to_string),
285        first_terminal_at: v
286            .get("firstTerminalAt")
287            .and_then(Value::as_str)
288            .map(str::to_string),
289        cli_version: v
290            .get("cliVersion")
291            .and_then(Value::as_str)
292            .map(str::to_string),
293        state_mtime_secs,
294    })
295}
296
297fn parse_timeline(path: &Path) -> Vec<JobEvent> {
298    let Ok(file) = fs::File::open(path) else {
299        return Vec::new();
300    };
301    let mut out = Vec::new();
302    for (i, line) in BufReader::new(file).lines().enumerate() {
303        let line = match line {
304            Ok(s) => s,
305            Err(e) => {
306                tracing::warn!(?path, "timeline line {i}: read error: {e}");
307                continue;
308            }
309        };
310        if line.trim().is_empty() {
311            continue;
312        }
313        match serde_json::from_str::<Value>(&line) {
314            Ok(v) => out.push(JobEvent {
315                at: v.get("at").and_then(Value::as_str).map(str::to_string),
316                state: v.get("state").and_then(Value::as_str).map(str::to_string),
317                detail: v.get("detail").and_then(Value::as_str).map(str::to_string),
318                text: v.get("text").and_then(Value::as_str).map(str::to_string),
319                extra: v,
320            }),
321            Err(e) => {
322                tracing::warn!(?path, "timeline line {i}: parse error: {e}");
323            }
324        }
325    }
326    out
327}
328
329fn home_dir() -> Option<PathBuf> {
330    if let Ok(h) = std::env::var("HOME")
331        && !h.is_empty()
332    {
333        return Some(PathBuf::from(h));
334    }
335    if let Ok(h) = std::env::var("USERPROFILE")
336        && !h.is_empty()
337    {
338        return Some(PathBuf::from(h));
339    }
340    None
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::io::Write;
347
348    /// Write a job dir at `root/<short_id>/` with the given state.json
349    /// body and optional timeline.jsonl lines.
350    fn write_job(root: &Path, short_id: &str, state_json: &str, timeline_lines: &[&str]) {
351        let dir = root.join(short_id);
352        fs::create_dir_all(&dir).expect("mkdir");
353        fs::write(dir.join("state.json"), state_json).expect("write state.json");
354        if !timeline_lines.is_empty() {
355            let mut f = fs::File::create(dir.join("timeline.jsonl")).expect("create timeline");
356            for line in timeline_lines {
357                writeln!(f, "{line}").unwrap();
358            }
359        }
360    }
361
362    fn fixture_root() -> tempfile::TempDir {
363        let tmp = tempfile::tempdir().expect("tempdir");
364        // A done job with full state + timeline.
365        write_job(
366            tmp.path(),
367            "aaaaaaaa",
368            r#"{"state":"done","detail":"42","intent":"meaning of life",
369                 "sessionId":"sess-aaa","linkScanPath":"/p/sess-aaa.jsonl",
370                 "cwd":"/work","createdAt":"2026-05-15T01:00:00Z",
371                 "updatedAt":"2026-05-15T01:01:00Z","firstTerminalAt":"2026-05-15T01:00:55Z",
372                 "name":"meaning of life","backend":"daemon","cliVersion":"2.1.143",
373                 "daemonShort":"aaaaaaaa","originCwd":"/work"}"#,
374            &[
375                r#"{"at":"2026-05-15T01:00:30Z","state":"running","detail":"thinking"}"#,
376                r#"{"at":"2026-05-15T01:00:55Z","state":"done","detail":"42","text":"the answer is 42"}"#,
377            ],
378        );
379        // A still-running job.
380        write_job(
381            tmp.path(),
382            "bbbbbbbb",
383            r#"{"state":"running","intent":"compute primes","sessionId":"sess-bbb"}"#,
384            &[r#"{"at":"2026-05-15T02:00:00Z","state":"running","detail":"started"}"#],
385        );
386        // A job dir with no state.json (spare worker leftover); list() should skip.
387        fs::create_dir_all(tmp.path().join("cccccccc")).unwrap();
388        // A non-directory top-level file (the daemon's pins.json); list() should skip.
389        fs::write(tmp.path().join("pins.json"), "[]").unwrap();
390        // A job whose state.json is malformed; list() should skip with warn.
391        write_job(tmp.path(), "deadbeef", "not valid json {{", &[]);
392        tmp
393    }
394
395    #[test]
396    fn list_returns_only_well_formed_jobs_sorted_by_short_id() {
397        let tmp = fixture_root();
398        let root = JobsRoot::at(tmp.path());
399        let jobs = root.list().expect("list");
400        let ids: Vec<&str> = jobs.iter().map(|j| j.short_id.as_str()).collect();
401        assert_eq!(ids, ["aaaaaaaa", "bbbbbbbb"]);
402    }
403
404    #[test]
405    fn list_missing_root_returns_empty() {
406        let tmp = tempfile::tempdir().expect("tempdir");
407        let root = JobsRoot::at(tmp.path().join("does-not-exist"));
408        assert!(root.list().expect("list").is_empty());
409    }
410
411    #[test]
412    fn list_summary_carries_typed_fields() {
413        let tmp = fixture_root();
414        let root = JobsRoot::at(tmp.path());
415        let jobs = root.list().expect("list");
416        let s = jobs.iter().find(|j| j.short_id == "aaaaaaaa").unwrap();
417        assert_eq!(s.state, "done");
418        assert_eq!(s.intent.as_deref(), Some("meaning of life"));
419        assert_eq!(s.session_id.as_deref(), Some("sess-aaa"));
420        assert_eq!(s.session_path, Some(PathBuf::from("/p/sess-aaa.jsonl")));
421        assert_eq!(s.cwd, Some(PathBuf::from("/work")));
422        assert_eq!(s.name.as_deref(), Some("meaning of life"));
423        assert_eq!(s.backend.as_deref(), Some("daemon"));
424        assert_eq!(s.cli_version.as_deref(), Some("2.1.143"));
425        assert_eq!(s.daemon_short.as_deref(), Some("aaaaaaaa"));
426        assert_eq!(s.origin_cwd, Some(PathBuf::from("/work")));
427        assert_eq!(s.created_at.as_deref(), Some("2026-05-15T01:00:00Z"));
428        assert_eq!(s.updated_at.as_deref(), Some("2026-05-15T01:01:00Z"));
429        assert_eq!(s.first_terminal_at.as_deref(), Some("2026-05-15T01:00:55Z"));
430        assert!(s.state_mtime_secs.is_some());
431    }
432
433    #[test]
434    fn list_running_job_has_no_first_terminal_at() {
435        let tmp = fixture_root();
436        let root = JobsRoot::at(tmp.path());
437        let jobs = root.list().expect("list");
438        let s = jobs.iter().find(|j| j.short_id == "bbbbbbbb").unwrap();
439        assert_eq!(s.state, "running");
440        assert!(s.first_terminal_at.is_none());
441    }
442
443    #[test]
444    fn get_returns_full_record_with_timeline() {
445        let tmp = fixture_root();
446        let root = JobsRoot::at(tmp.path());
447        let job = root.get("aaaaaaaa").expect("get");
448        assert_eq!(job.summary.state, "done");
449        assert_eq!(job.timeline.len(), 2);
450        assert_eq!(job.timeline[0].state.as_deref(), Some("running"));
451        assert_eq!(job.timeline[1].state.as_deref(), Some("done"));
452        assert_eq!(job.timeline[1].text.as_deref(), Some("the answer is 42"));
453        assert!(!job.raw_state.is_null());
454    }
455
456    #[test]
457    fn get_no_timeline_returns_empty_vec() {
458        // running job's timeline only has 1 line; spare leftover has none.
459        // Build a fresh job with no timeline file.
460        let tmp = tempfile::tempdir().expect("tempdir");
461        write_job(
462            tmp.path(),
463            "ffffffff",
464            r#"{"state":"queued","intent":"x","sessionId":"y"}"#,
465            &[],
466        );
467        let root = JobsRoot::at(tmp.path());
468        let job = root.get("ffffffff").expect("get");
469        assert!(job.timeline.is_empty());
470    }
471
472    #[test]
473    fn get_unknown_id_errors() {
474        let tmp = fixture_root();
475        let root = JobsRoot::at(tmp.path());
476        let err = root.get("nope").unwrap_err();
477        assert!(err.to_string().contains("no job"));
478    }
479
480    #[test]
481    fn timeline_skips_malformed_lines_without_failing() {
482        let tmp = tempfile::tempdir().expect("tempdir");
483        write_job(
484            tmp.path(),
485            "mixed",
486            r#"{"state":"done","intent":"x","sessionId":"y"}"#,
487            &[
488                r#"{"at":"t1","state":"running"}"#,
489                r#"NOT VALID JSON"#,
490                r#""#, // empty line
491                r#"{"at":"t2","state":"done","text":"final"}"#,
492            ],
493        );
494        let root = JobsRoot::at(tmp.path());
495        let job = root.get("mixed").expect("get");
496        assert_eq!(job.timeline.len(), 2);
497        assert_eq!(job.timeline[0].at.as_deref(), Some("t1"));
498        assert_eq!(job.timeline[1].at.as_deref(), Some("t2"));
499        assert_eq!(job.timeline[1].text.as_deref(), Some("final"));
500    }
501
502    #[test]
503    fn unknown_state_string_passes_through() {
504        // Forward-compat: future daemon states shouldn't break us.
505        let tmp = tempfile::tempdir().expect("tempdir");
506        write_job(
507            tmp.path(),
508            "weirdstate",
509            r#"{"state":"some-future-state","intent":"x","sessionId":"y"}"#,
510            &[],
511        );
512        let root = JobsRoot::at(tmp.path());
513        let job = root.get("weirdstate").expect("get");
514        assert_eq!(job.summary.state, "some-future-state");
515    }
516
517    #[test]
518    fn raw_state_preserves_unknown_fields() {
519        let tmp = tempfile::tempdir().expect("tempdir");
520        write_job(
521            tmp.path(),
522            "extras",
523            r#"{"state":"done","intent":"x","sessionId":"y",
524                 "futureField":{"nested":42},"tempo":"idle"}"#,
525            &[],
526        );
527        let root = JobsRoot::at(tmp.path());
528        let job = root.get("extras").expect("get");
529        assert_eq!(job.raw_state["futureField"]["nested"], 42);
530        assert_eq!(job.raw_state["tempo"], "idle");
531    }
532
533    #[test]
534    fn missing_state_field_defaults_to_unknown() {
535        let tmp = tempfile::tempdir().expect("tempdir");
536        write_job(tmp.path(), "nostate", r#"{"intent":"x"}"#, &[]);
537        let root = JobsRoot::at(tmp.path());
538        let summary = &root.list().expect("list")[0];
539        assert_eq!(summary.state, "unknown");
540    }
541
542    // -- live test against the real ~/.claude/jobs/ ----------------
543
544    #[test]
545    #[ignore = "reads the user's real ~/.claude/jobs; may be empty"]
546    fn live_list_real_jobs_dir() {
547        let root = JobsRoot::home().expect("home dir");
548        // Just shape: no panics, returns a Vec, every entry has at
549        // least a short_id and a state string.
550        for s in root.list().expect("list") {
551            assert!(!s.short_id.is_empty(), "empty short_id: {s:?}");
552            assert!(!s.state.is_empty(), "empty state: {s:?}");
553        }
554    }
555}