use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const DAEMON_DIR_ENV: &str = "FNO_CLAUDE_DAEMON_DIR";
pub fn daemon_dir() -> PathBuf {
if let Some(v) = std::env::var_os(DAEMON_DIR_ENV) {
return PathBuf::from(v);
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".claude").join("daemon")
}
pub fn default_roster_path() -> PathBuf {
daemon_dir().join("roster.json")
}
pub fn control_key_path() -> PathBuf {
daemon_dir().join("control.key")
}
pub fn read_control_key() -> Option<String> {
let raw = std::fs::read_to_string(control_key_path()).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RosterWorker {
pub session_id: String,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default, deserialize_with = "de_lenient_opt_u64")]
pub proc_start: Option<u64>,
#[serde(default)]
pub pty_sock: Option<String>,
#[serde(default)]
pub pty_auth: Option<String>,
#[serde(default)]
pub cli_version: Option<String>,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub worktree_path: Option<String>,
}
fn de_lenient_opt_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Number(n) => Ok(n.as_u64()),
serde_json::Value::String(s) => Ok(s.trim().parse::<u64>().ok()),
_ => Ok(None),
}
}
impl RosterWorker {
pub fn short_id(&self) -> &str {
self.session_id
.split('-')
.next()
.unwrap_or(&self.session_id)
}
pub fn resolve_control_sock(&self) -> Option<PathBuf> {
let pty = self.pty_sock.as_deref()?;
control_sock_from_ptysock(Path::new(pty))
}
}
fn control_sock_from_ptysock(pty_sock: &Path) -> Option<PathBuf> {
let mut cur = pty_sock.parent();
while let Some(dir) = cur {
let cand = dir.join("control.sock");
if cand.exists() {
return Some(cand);
}
cur = dir.parent();
}
None
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeRoster {
#[serde(default)]
pub proto: u32,
#[serde(default)]
pub supervisor_pid: Option<u32>,
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub workers: std::collections::BTreeMap<String, RosterWorker>,
}
impl ClaudeRoster {
pub fn parse(bytes: &[u8]) -> serde_json::Result<Self> {
serde_json::from_slice(bytes)
}
pub fn load(path: &Path) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
Self::parse(&bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn load_default() -> std::io::Result<Self> {
let path = default_roster_path();
match std::fs::read(&path) {
Ok(bytes) => Self::parse(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ClaudeRoster {
proto: 0,
supervisor_pid: None,
updated_at: None,
workers: Default::default(),
}),
Err(e) => Err(e),
}
}
pub fn workers_deduped(&self) -> Vec<&RosterWorker> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<&RosterWorker> = self
.workers
.values()
.filter(|w| seen.insert(w.session_id.as_str()))
.collect();
out.sort_by(|a, b| a.session_id.cmp(&b.session_id));
out
}
pub fn find(&self, session_or_short: &str) -> Option<&RosterWorker> {
self.workers
.values()
.find(|w| w.session_id == session_or_short || w.short_id() == session_or_short)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"proto": 1,
"supervisorPid": 4242,
"updatedAt": 1751049130000,
"workers": {
"a1b2c3d4": {
"pid": 5001,
"procStart": 99887766,
"sessionId": "a1b2c3d4-1111-2222-3333-444455556666",
"rendezvousSock": "/tmp/cc-daemon-501/deadbeef/rv/a1b2c3d4.rv.sock",
"ptySock": "/tmp/cc-daemon-501/deadbeef/spare/a1b2c3d4.pty.sock",
"cliVersion": "2.1.195",
"startedAt": 1751049000000,
"attempt": 1,
"cwd": "/Users/x/code/proj",
"dispatch": {"source": "shell"},
"rvAuth": "aaaa1111bbbb2222",
"ptyAuth": "cccc3333dddd4444"
},
"ee99ff00": {
"pid": 5002,
"sessionId": "ee99ff00-7777-8888-9999-aaaabbbbcccc",
"ptySock": "/tmp/cc-daemon-501/deadbeef/pty/ee99ff00.pty.sock",
"startedAt": 1751049050000,
"attempt": 2,
"cwd": "/Users/x/code/other",
"dispatch": {"source": "fleet"}
}
}
}"#;
#[test]
fn parses_confirmed_roster_shape() {
let r = ClaudeRoster::parse(SAMPLE.as_bytes()).expect("parse");
assert_eq!(r.proto, 1);
assert_eq!(r.supervisor_pid, Some(4242));
assert_eq!(r.workers.len(), 2);
let w = &r.workers["a1b2c3d4"];
assert_eq!(w.session_id, "a1b2c3d4-1111-2222-3333-444455556666");
assert_eq!(w.pid, Some(5001));
assert_eq!(w.proc_start, Some(99887766));
assert_eq!(w.cli_version.as_deref(), Some("2.1.195"));
assert_eq!(w.cwd, "/Users/x/code/proj");
assert_eq!(w.pty_auth.as_deref(), Some("cccc3333dddd4444"));
}
const SAMPLE_STRING_PROCSTART: &str = r#"{
"proto": 1,
"supervisorPid": 77901,
"workers": {
"6269e385": {
"pid": 6001,
"procStart": "Mon Jun 29 00:11:16 2026",
"sessionId": "6269e385-1111-2222-3333-444455556666",
"ptySock": "/tmp/cc-daemon-501/608d3bdb/spare/6269e385.pty.sock",
"cliVersion": "2.1.199",
"cwd": "/Users/bb16/code/footnote/footnote"
},
"d712218d": {
"pid": 6002,
"procStart": "Mon Jun 29 00:11:22 2026",
"sessionId": "d712218d-7777-8888-9999-aaaabbbbcccc",
"ptySock": "/tmp/cc-daemon-501/608d3bdb/spare/d712218d.pty.sock",
"cwd": "/Users/bb16/code/footnote/footnote"
}
}
}"#;
#[test]
fn parses_roster_with_string_procstart_drift() {
let r = ClaudeRoster::parse(SAMPLE_STRING_PROCSTART.as_bytes())
.expect("string procStart must not fail the whole-roster parse");
assert_eq!(r.workers.len(), 2, "both workers survive the drift");
let w = &r.workers["6269e385"];
assert_eq!(w.session_id, "6269e385-1111-2222-3333-444455556666");
assert_eq!(w.pid, Some(6001));
assert_eq!(w.proc_start, None);
assert_eq!(w.cwd, "/Users/bb16/code/footnote/footnote");
}
#[test]
fn lenient_procstart_accepts_number_numeric_string_and_null() {
let num = r#"{"workers":{"a":{"sessionId":"a-1","procStart":12345}}}"#;
assert_eq!(
ClaudeRoster::parse(num.as_bytes()).unwrap().workers["a"].proc_start,
Some(12345)
);
let numstr = r#"{"workers":{"a":{"sessionId":"a-1","procStart":"12345"}}}"#;
assert_eq!(
ClaudeRoster::parse(numstr.as_bytes()).unwrap().workers["a"].proc_start,
Some(12345)
);
let null = r#"{"workers":{"a":{"sessionId":"a-1","procStart":null}}}"#;
assert_eq!(
ClaudeRoster::parse(null.as_bytes()).unwrap().workers["a"].proc_start,
None
);
let absent = r#"{"workers":{"a":{"sessionId":"a-1"}}}"#;
assert_eq!(
ClaudeRoster::parse(absent.as_bytes()).unwrap().workers["a"].proc_start,
None
);
}
#[test]
fn short_id_is_first_hex_segment() {
let r = ClaudeRoster::parse(SAMPLE.as_bytes()).unwrap();
assert_eq!(r.workers["a1b2c3d4"].short_id(), "a1b2c3d4");
assert_eq!(r.workers["ee99ff00"].short_id(), "ee99ff00");
}
#[test]
fn dedup_is_deterministic_by_session_id() {
let r = ClaudeRoster::parse(SAMPLE.as_bytes()).unwrap();
let w = r.workers_deduped();
assert_eq!(w.len(), 2);
assert_eq!(w[0].short_id(), "a1b2c3d4");
assert_eq!(w[1].short_id(), "ee99ff00");
}
#[test]
fn find_by_session_or_short() {
let r = ClaudeRoster::parse(SAMPLE.as_bytes()).unwrap();
assert!(r.find("a1b2c3d4").is_some());
assert!(r.find("ee99ff00-7777-8888-9999-aaaabbbbcccc").is_some());
assert!(r.find("nope").is_none());
}
#[test]
fn malformed_roster_is_error() {
assert!(ClaudeRoster::parse(b"{ not json").is_err());
}
#[test]
fn missing_roster_loads_empty() {
let tmp = std::env::temp_dir().join(format!(
"fno-roster-missing-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::env::set_var(DAEMON_DIR_ENV, &tmp);
let r = ClaudeRoster::load_default().expect("degrades to empty");
assert!(r.workers.is_empty());
std::env::remove_var(DAEMON_DIR_ENV);
}
#[test]
fn resolve_control_sock_walks_up_from_ptysock() {
let base = std::env::temp_dir().join(format!(
"fno-ctrlsock-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let ddir = base.join("cc-daemon-501").join("deadbeef");
std::fs::create_dir_all(ddir.join("spare")).unwrap();
let ctrl = ddir.join("control.sock");
std::fs::write(&ctrl, b"").unwrap();
let pty = ddir.join("spare").join("x.pty.sock");
std::fs::write(&pty, b"").unwrap();
let w = RosterWorker {
session_id: "x".into(),
pid: None,
proc_start: None,
pty_sock: Some(pty.to_string_lossy().into_owned()),
pty_auth: None,
cli_version: None,
cwd: String::new(),
worktree_path: None,
};
assert_eq!(w.resolve_control_sock().unwrap(), ctrl);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn resolve_control_sock_none_when_absent() {
let base = std::env::temp_dir().join(format!(
"fno-ctrlsock-none-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let dir = base.join("d").join("spare");
std::fs::create_dir_all(&dir).unwrap();
let pty = dir.join("x.pty.sock");
std::fs::write(&pty, b"").unwrap();
let w = RosterWorker {
session_id: "x".into(),
pid: None,
proc_start: None,
pty_sock: Some(pty.to_string_lossy().into_owned()),
pty_auth: None,
cli_version: None,
cwd: String::new(),
worktree_path: None,
};
assert!(w.resolve_control_sock().is_none());
std::fs::remove_dir_all(&base).ok();
}
}