use crate::config;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default)]
pub struct Registry {
pub enabled: bool,
#[serde(default)]
pub targets: Vec<Target>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Target {
pub hub: String,
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
}
pub const SESSION_ENV_KEYS: &[&str] = &["CLAUDE_CODE_SESSION_ID", "GROK_SESSION_ID"];
pub(crate) fn session_from(get: impl Fn(&str) -> Option<String>, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|k| get(k).filter(|s| !s.is_empty()))
}
pub fn current_session() -> Option<String> {
session_from(|k| std::env::var(k).ok(), SESSION_ENV_KEYS).or_else(grok_session_from_disk)
}
fn grok_session_from_disk() -> Option<String> {
std::env::var("GROK_AGENT").ok().filter(|s| !s.is_empty())?;
let path = config::home().ok()?.join(".grok").join("active_sessions.json");
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
let cwd = std::env::current_dir().ok();
pick_grok_session(&v, &ancestor_pids(), cwd.as_deref().and_then(|p| p.to_str()))
}
fn pick_grok_session(
v: &serde_json::Value,
ancestors: &std::collections::HashSet<u32>,
cwd: Option<&str>,
) -> Option<String> {
let entries: Vec<&serde_json::Value> = match v {
serde_json::Value::Array(a) => a.iter().collect(),
serde_json::Value::Object(_) => vec![v],
_ => return None,
};
if entries.is_empty() {
return None;
}
let sid = |e: &serde_json::Value| e.get("session_id").and_then(|x| x.as_str()).map(String::from);
if !ancestors.is_empty() {
let m: Vec<&&serde_json::Value> = entries
.iter()
.filter(|e| e.get("pid").and_then(|p| p.as_u64()).is_some_and(|p| ancestors.contains(&(p as u32))))
.collect();
if let [only] = m.as_slice() {
return sid(only);
}
}
if let Some(cwd) = cwd {
let m: Vec<&&serde_json::Value> = entries
.iter()
.filter(|e| e.get("cwd").and_then(|c| c.as_str()) == Some(cwd))
.collect();
if let [only] = m.as_slice() {
return sid(only);
}
}
if let [only] = entries.as_slice() {
return sid(only);
}
None
}
fn ancestor_pids() -> std::collections::HashSet<u32> {
let mut set = std::collections::HashSet::new();
let mut pid = std::process::id();
for _ in 0..64 {
if !set.insert(pid) {
break; }
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
break;
};
let Some((_, after)) = stat.rsplit_once(')') else { break };
let Some(ppid) = after.split_whitespace().nth(1).and_then(|p| p.parse::<u32>().ok()) else {
break;
};
if ppid == 0 || ppid == pid {
break;
}
pid = ppid;
}
set
}
fn path() -> Result<PathBuf> {
Ok(config::home()?.join(".confer").join("autoheal.json"))
}
pub fn load() -> Registry {
path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(r: &Registry) -> Result<()> {
let p = path()?;
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(p, serde_json::to_string_pretty(r)?)?;
Ok(())
}
pub fn set_enabled(on: bool) -> Result<()> {
let mut r = load();
r.enabled = on;
save(&r)
}
pub fn add_target(hub: &str, role: &str, session_override: Option<String>) {
if role.is_empty() {
return;
}
let mut r = load();
let session = session_override.or_else(current_session);
if session.is_none() && std::env::var("GROK_AGENT").is_ok() {
crate::hint(
"session id unknown (Grok exposes it only to hooks) — watch ownership is role-only; \
fine for a single session, but pass `confer arm --session <id>` (e.g. from \
~/.grok/active_sessions.json) if you run several Grok sessions on this machine.",
);
}
if let Some(t) = r.targets.iter_mut().find(|t| t.hub == hub && t.role == role) {
if t.session != session {
t.session = session;
let _ = save(&r);
}
} else {
r.targets.push(Target { hub: hub.to_string(), role: role.to_string(), session });
let _ = save(&r);
}
}
pub fn retarget(old: &str, new: &str) {
let mut r = load();
let mut changed = false;
for t in &mut r.targets {
if t.hub == old {
t.hub = new.to_string();
changed = true;
}
}
if changed {
let _ = save(&r);
}
}
pub fn stale_targets() -> Vec<Target> {
load()
.targets
.into_iter()
.filter(|t| !std::path::Path::new(&t.hub).exists())
.collect()
}
pub fn prune() -> Vec<Target> {
let mut r = load();
let (live, dead): (Vec<Target>, Vec<Target>) = r
.targets
.drain(..)
.partition(|t| std::path::Path::new(&t.hub).exists());
if !dead.is_empty() {
r.targets = live;
let _ = save(&r);
}
dead
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Ownership {
Session,
Role,
}
pub fn ownership(
t: &Target,
me_session: &Option<String>,
me_role: &Option<String>,
) -> Option<Ownership> {
if let Some(s) = me_session {
if t.session.as_deref() == Some(s.as_str()) {
return Some(Ownership::Session);
}
}
if let Some(r) = me_role {
if &t.role == r {
return Some(Ownership::Role);
}
}
None
}
pub fn owned_by_session(
t: &Target,
me_session: &Option<String>,
me_role: &Option<String>,
) -> bool {
ownership(t, me_session, me_role).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
fn t(session: Option<&str>, role: &str) -> Target {
Target { hub: "/x".into(), role: role.into(), session: session.map(String::from) }
}
#[test]
fn scoping_owns_my_session_and_my_role_but_never_a_peer() {
let me_s = Some("sess-A".to_string());
let me_r = Some("alice".to_string());
assert!(owned_by_session(&t(Some("sess-A"), "alice"), &me_s, &me_r));
assert!(owned_by_session(&t(Some("sess-OLD"), "alice"), &me_s, &me_r));
assert!(owned_by_session(&t(None, "alice"), &me_s, &me_r));
assert!(!owned_by_session(&t(Some("sess-B"), "carol"), &me_s, &me_r));
assert!(!owned_by_session(&t(None, "carol"), &me_s, &me_r));
}
#[test]
fn session_from_tries_each_harness_key_in_order() {
let g = |k: &str| (k == "GROK_SESSION_ID").then(|| "grok-1".to_string());
assert_eq!(session_from(g, SESSION_ENV_KEYS).as_deref(), Some("grok-1"));
let both = |k: &str| match k {
"CLAUDE_CODE_SESSION_ID" => Some("cc-1".to_string()),
"GROK_SESSION_ID" => Some("grok-1".to_string()),
_ => None,
};
assert_eq!(session_from(both, SESSION_ENV_KEYS).as_deref(), Some("cc-1"));
let empty_claude = |k: &str| match k {
"CLAUDE_CODE_SESSION_ID" => Some(String::new()),
"GROK_SESSION_ID" => Some("grok-1".to_string()),
_ => None,
};
assert_eq!(session_from(empty_claude, SESSION_ENV_KEYS).as_deref(), Some("grok-1"));
assert_eq!(session_from(|_| None, SESSION_ENV_KEYS), None);
}
#[test]
fn pick_grok_session_matches_narrowly_or_declines() {
use std::collections::HashSet;
let j = serde_json::json!([
{"session_id": "s-anc", "pid": 4242, "cwd": "/w/a"},
{"session_id": "s-cwd", "pid": 9999, "cwd": "/w/b"},
]);
let anc: HashSet<u32> = [1u32, 4242].into_iter().collect();
assert_eq!(pick_grok_session(&j, &anc, Some("/w/zzz")).as_deref(), Some("s-anc"));
let none: HashSet<u32> = HashSet::new();
assert_eq!(pick_grok_session(&j, &none, Some("/w/b")).as_deref(), Some("s-cwd"));
assert_eq!(pick_grok_session(&j, &none, Some("/w/zzz")), None);
let one = serde_json::json!({"session_id": "solo", "pid": 7, "cwd": "/w/x"});
assert_eq!(pick_grok_session(&one, &none, Some("/nope")).as_deref(), Some("solo"));
assert_eq!(pick_grok_session(&serde_json::json!([]), &none, None), None);
assert_eq!(pick_grok_session(&serde_json::json!("nope"), &none, None), None);
}
#[test]
fn ownership_basis_distinguishes_session_from_role_fallback() {
let me_s = Some("sess-A".to_string());
let me_r = Some("alice".to_string());
assert_eq!(ownership(&t(Some("sess-A"), "alice"), &me_s, &me_r), Some(Ownership::Session));
assert_eq!(ownership(&t(Some("sess-OLD"), "alice"), &me_s, &me_r), Some(Ownership::Role));
assert_eq!(ownership(&t(None, "alice"), &me_s, &me_r), Some(Ownership::Role));
assert_eq!(ownership(&t(Some("sess-B"), "carol"), &me_s, &me_r), None);
}
#[test]
fn no_identity_owns_nothing_rather_than_over_listing() {
let none: Option<String> = None;
assert!(!owned_by_session(&t(Some("sess-A"), "alice"), &none, &none));
assert!(!owned_by_session(&t(None, "alice"), &none, &none));
let me_r = Some("alice".to_string());
assert!(owned_by_session(&t(None, "alice"), &none, &me_r));
assert!(!owned_by_session(&t(None, "carol"), &none, &me_r));
}
}