use std::path::Path;
use crate::claude_roster::RosterWorker;
use crate::state::{update_registry, RegistryEntry, StateError, HOST_MODE_ATTACHED};
use crate::AgentStatus;
pub fn pty_claim_holder(short_id: &str) -> String {
format!("pty:{short_id}")
}
pub fn adopted_name(short_id: &str) -> String {
format!("cc-{short_id}")
}
pub fn mint_adopted_entry(w: &RosterWorker, now: &str) -> RegistryEntry {
let short = w.short_id().to_string();
RegistryEntry {
name: adopted_name(&short),
short_id: short,
legacy_provider: String::new(),
harness: Some("claude".into()),
harness_session_id: Some(w.session_id.clone()),
cwd: w.cwd.clone(),
project_root: w.worktree_path.clone().unwrap_or_else(|| w.cwd.clone()),
session_id: None,
claude_session_uuid: Some(w.session_id.clone()),
messaging_socket_path: None,
codex_session_id: None,
gemini_session_id: None,
mcp_channel_id: None,
cc_session_id: None,
host_mode: Some(HOST_MODE_ATTACHED.into()),
status: AgentStatus::Live,
last_message_at: Some(now.to_string()),
created_at: now.to_string(),
pid: w.pid,
pid_start_time: w.proc_start,
log_path: None,
last_reconciled_at: None,
inside_leg: None,
exited_at: None,
mux: None,
screen_state: None,
crown_level: None,
crown_scope: None,
crown_grantor: None,
legacy_claude_short_id: None,
}
}
pub fn upsert_adopted_row(registry_path: &Path, entry: RegistryEntry) -> Result<(), StateError> {
update_registry(registry_path, |reg| {
let key = entry.claude_session_uuid.as_deref();
let idx = key.and_then(|k| {
reg.entries
.iter()
.position(|e| e.claude_session_uuid.as_deref() == Some(k))
});
match idx {
Some(i) => reg.entries[i] = entry,
None => reg.entries.push(entry),
}
})
}
#[derive(Debug, Clone, PartialEq)]
pub enum ClaimOutcome {
Acquired,
HeldByOther(String),
Unavailable(String),
}
pub fn acquire_pty_claim(uuid: &str, holder: &str, holder_pid: u32) -> ClaimOutcome {
match crate::claims::acquire(
&format!("session:{uuid}"),
holder,
crate::claims::AcquireOpts {
pid: Some(holder_pid),
..Default::default()
},
) {
crate::claims::AcquireOutcome::Acquired(_) => ClaimOutcome::Acquired,
crate::claims::AcquireOutcome::HeldByOther { holder, .. } => {
ClaimOutcome::HeldByOther(holder)
}
crate::claims::AcquireOutcome::Error(e) => ClaimOutcome::Unavailable(e),
}
}
pub fn adopt(
registry_path: &Path,
worker: &RosterWorker,
holder_pid: u32,
) -> Result<RegistryEntry, AdoptError> {
let short = worker.short_id().to_string();
let holder = pty_claim_holder(&short);
match acquire_pty_claim(&worker.session_id, &holder, holder_pid) {
ClaimOutcome::HeldByOther(who) => {
return Err(AdoptError::HeldByOther(who));
}
ClaimOutcome::Acquired | ClaimOutcome::Unavailable(_) => {}
}
let entry = mint_adopted_entry(worker, &crate::daemon::now_rfc3339_like());
upsert_adopted_row(registry_path, entry.clone()).map_err(AdoptError::Registry)?;
Ok(entry)
}
#[derive(Debug)]
pub enum AdoptError {
HeldByOther(String),
Registry(StateError),
}
impl std::fmt::Display for AdoptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AdoptError::HeldByOther(who) => write!(f, "session already held by {who}"),
AdoptError::Registry(e) => write!(f, "registry write failed: {e}"),
}
}
}
impl std::error::Error for AdoptError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::HOST_MODE_INTERACTIVE;
fn worker() -> RosterWorker {
RosterWorker {
session_id: "a1b2c3d4-1111-2222-3333-444455556666".into(),
pid: Some(5001),
proc_start: Some(99887766),
pty_sock: Some("/tmp/cc-daemon-501/deadbeef/spare/a1b2c3d4.pty.sock".into()),
pty_auth: Some("cccc3333dddd4444".into()),
cli_version: Some("2.1.195".into()),
cwd: "/Users/x/code/proj".into(),
worktree_path: None,
}
}
#[test]
fn holder_and_name_formats() {
assert_eq!(pty_claim_holder("a1b2c3d4"), "pty:a1b2c3d4");
assert_eq!(adopted_name("a1b2c3d4"), "cc-a1b2c3d4");
}
#[test]
fn mint_sets_attached_marker_and_resume_key() {
let e = mint_adopted_entry(&worker(), "2026-06-27T17:00:00Z");
assert_eq!(e.name, "cc-a1b2c3d4");
assert_eq!(e.harness_name(), "claude");
assert_eq!(e.host_mode.as_deref(), Some("attached"));
assert_eq!(
e.claude_session_uuid.as_deref(),
Some("a1b2c3d4-1111-2222-3333-444455556666")
);
assert_eq!(e.short_id, "a1b2c3d4");
assert_eq!(e.pid, Some(5001));
assert_eq!(e.pid_start_time, Some(99887766));
assert_eq!(e.status, AgentStatus::Live);
}
#[test]
fn attached_row_is_not_interactive_and_not_one_shot() {
let e = mint_adopted_entry(&worker(), "2026-06-27T17:00:00Z");
assert!(!e.is_interactive());
assert_ne!(e.host_mode_or_default(), HOST_MODE_INTERACTIVE);
assert!(!e.is_one_shot_ask(), "adopted row with pid present");
}
#[test]
fn upsert_replaces_by_session_uuid() {
let dir = std::env::temp_dir().join(format!(
"fno-adopt-upsert-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let reg = dir.join("registry.json");
let e1 = mint_adopted_entry(&worker(), "2026-06-27T17:00:00Z");
upsert_adopted_row(®, e1).unwrap();
let mut e2 = mint_adopted_entry(&worker(), "2026-06-27T18:00:00Z");
e2.cwd = "/Users/x/code/moved".into();
upsert_adopted_row(®, e2).unwrap();
let loaded = crate::state::load_registry(®).unwrap();
let rows: Vec<_> = loaded
.entries
.iter()
.filter(|e| {
e.claude_session_uuid.as_deref() == Some("a1b2c3d4-1111-2222-3333-444455556666")
})
.collect();
assert_eq!(rows.len(), 1, "upsert must not duplicate");
assert_eq!(rows[0].cwd, "/Users/x/code/moved");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn acquire_pty_claim_anchors_to_holder_pid_and_maps_outcomes() {
let td = std::env::temp_dir().join(format!(
"fno-adopt-claim-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&td).unwrap();
let _guard = crate::claims::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("FNO_CLAIMS_ROOT", &td);
let me = std::process::id();
assert_eq!(
acquire_pty_claim("uuid-1", "pty:a1b2c3d4", me),
ClaimOutcome::Acquired
);
let (_, rec) = crate::claims::status("session:uuid-1", None);
assert_eq!(rec.unwrap().pid, me as i32);
assert_eq!(
acquire_pty_claim("uuid-1", "pty:other", me),
ClaimOutcome::HeldByOther("pty:a1b2c3d4".into())
);
std::env::remove_var("FNO_CLAIMS_ROOT");
std::fs::remove_dir_all(&td).ok();
}
}