use super::paths as claude;
use crate::atomic;
use crate::context::Context;
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub struct Daemon {
pub pid: u32,
pub version: Option<String>,
pub started_at: Option<i64>,
pub origin: Option<String>,
pub launch_target: Option<PathBuf>,
pub running: bool,
}
pub fn read(ctx: &Context) -> Option<Daemon> {
parse(
&std::fs::read_to_string(lock_path(ctx)).ok()?,
atomic::may_be_running,
)
}
pub fn lock_path(ctx: &Context) -> PathBuf {
claude::config_dir(ctx).join("daemon.lock")
}
fn parse(raw: &str, alive: fn(u32) -> bool) -> Option<Daemon> {
let json: Value = serde_json::from_str(raw).ok()?;
let pid: u32 = json.get("pid").and_then(Value::as_u64)?.try_into().ok()?;
Some(Daemon {
pid,
version: text(&json, "version"),
started_at: json.get("startedAt").and_then(Value::as_i64),
origin: text(&json, "origin"),
launch_target: text(&json, "launchTarget").map(PathBuf::from),
running: alive(pid),
})
}
fn text(json: &Value, key: &str) -> Option<String> {
json.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
const LOCK: &str = r#"{
"pid": 46789,
"version": "2.1.278",
"jsonPath": "/home/someone/.claude/daemon.json",
"startedAt": 1790079766317,
"origin": "transient",
"procStart": "Tue Sep 22 12:22:46 2026",
"launchTarget": "/home/someone/.local/share/claude/versions/2.1.278",
"processWrapper": ""
}"#;
fn dead(_: u32) -> bool {
false
}
fn live(_: u32) -> bool {
true
}
#[test]
fn reads_what_the_daemon_wrote() {
let d = parse(LOCK, live).expect("a lock file with a pid parses");
assert_eq!(d.pid, 46789);
assert_eq!(d.version.as_deref(), Some("2.1.278"));
assert_eq!(d.started_at, Some(1790079766317));
assert_eq!(d.origin.as_deref(), Some("transient"));
assert_eq!(
d.launch_target,
Some(PathBuf::from(
"/home/someone/.local/share/claude/versions/2.1.278"
))
);
assert!(d.running);
}
#[test]
fn a_lock_left_behind_by_a_dead_daemon_is_not_running() {
assert!(!parse(LOCK, dead).expect("parses").running);
}
#[test]
fn empty_strings_are_absences() {
let d = parse(r#"{"pid": 1, "version": "", "processWrapper": ""}"#, dead)
.expect("a pid is all that is required");
assert_eq!(d.version, None);
assert_eq!(d.origin, None);
assert_eq!(d.launch_target, None);
assert_eq!(d.started_at, None);
}
#[test]
fn without_a_pid_there_is_nothing_to_report() {
assert_eq!(parse(r#"{"version": "2.1.278"}"#, live), None);
assert_eq!(parse(r#"{"pid": -1}"#, live), None);
assert_eq!(parse("not json", live), None);
}
}