use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::comms::ids::AgentId;
pub const AGENT_ID_ENV: &str = "BASEMIND_AGENT_ID";
pub const AGENT_ID_FILE: &str = "agent-id";
pub const CLAIMS_DIR: &str = "agents";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IdentitySource {
Env,
Config,
Workspace,
Generated,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdentityCollision {
pub agent_id: String,
pub claimed_by_root: PathBuf,
pub claimed_by_pid: u32,
pub our_root: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdentityPaths {
pub workspace_cache_dir: PathBuf,
pub claims_dir: PathBuf,
}
impl IdentityPaths {
pub fn for_root(root: &Path) -> Self {
Self {
workspace_cache_dir: crate::store::workspace_cache_dir(root),
claims_dir: claims_dir(),
}
}
}
pub fn claims_dir() -> PathBuf {
crate::store::cache_root()
.join(crate::store::CACHE_DIR)
.join(CLAIMS_DIR)
}
#[derive(Clone, Debug)]
pub struct IdentityRequest<'a> {
pub root: &'a Path,
pub paths: IdentityPaths,
pub env_agent_id: Option<String>,
pub config_agent_id: Option<String>,
}
impl<'a> IdentityRequest<'a> {
pub fn from_process(root: &'a Path, config_agent_id: Option<String>) -> Self {
Self {
root,
paths: IdentityPaths::for_root(root),
env_agent_id: std::env::var(AGENT_ID_ENV).ok(),
config_agent_id,
}
}
}
#[derive(Clone, Debug)]
pub struct AgentIdentity {
id: AgentId,
source: IdentitySource,
collision: Option<IdentityCollision>,
}
impl AgentIdentity {
pub fn id(&self) -> &AgentId {
&self.id
}
pub fn into_id(self) -> AgentId {
self.id
}
pub fn source(&self) -> IdentitySource {
self.source
}
pub fn collision(&self) -> Option<&IdentityCollision> {
self.collision.as_ref()
}
pub fn collision_warning(&self) -> Option<String> {
let c = self.collision.as_ref()?;
Some(format!(
"agent id {:?} is already claimed by another workspace (pid {} in {}); this process is \
in {}. Both will share ONE inbox — each will see the other's messages as its own. \
Unset BASEMIND_AGENT_ID (or comms.agent_id) in one of them to get distinct identities.",
c.agent_id,
c.claimed_by_pid,
c.claimed_by_root.display(),
c.our_root.display(),
))
}
}
pub fn resolve(request: &IdentityRequest<'_>) -> AgentIdentity {
let (id, source) = resolve_id(request);
let collision = record_claim(&request.paths.claims_dir, &id, request.root);
let identity = AgentIdentity { id, source, collision };
if let Some(warning) = identity.collision_warning() {
tracing::warn!(
agent_id = %identity.id,
source = ?identity.source,
"basemind: AGENT IDENTITY COLLISION — {warning}"
);
}
identity
}
pub fn resolve_for_root(root: &Path, config_agent_id: Option<String>) -> AgentIdentity {
resolve(&IdentityRequest::from_process(root, config_agent_id))
}
pub fn cli_agent_id(root: &Path) -> AgentId {
let config_agent_id = crate::config::load(root).ok().and_then(|config| config.comms.agent_id);
let identity = resolve_for_root(root, config_agent_id);
if let Some(warning) = identity.collision_warning() {
eprintln!("warning: {warning}");
}
identity.into_id()
}
fn resolve_id(request: &IdentityRequest<'_>) -> (AgentId, IdentitySource) {
let validated = |candidate: Option<&str>| candidate.and_then(|s| AgentId::parse(s).ok());
if let Some(id) = validated(request.env_agent_id.as_deref()) {
return (id, IdentitySource::Env);
}
if let Some(id) = validated(request.config_agent_id.as_deref()) {
return (id, IdentitySource::Config);
}
if let Some(id) = load_or_create_workspace_id(&request.paths.workspace_cache_dir) {
return (id, IdentitySource::Workspace);
}
(generated_id("agent"), IdentitySource::Generated)
}
fn load_or_create_workspace_id(workspace_cache_dir: &Path) -> Option<AgentId> {
let path = workspace_cache_dir.join(AGENT_ID_FILE);
if let Some(id) = read_agent_id(&path) {
return Some(id);
}
std::fs::create_dir_all(workspace_cache_dir).ok()?;
let candidate = generated_id("session");
match std::fs::File::options().write(true).create_new(true).open(&path) {
Ok(mut file) => {
use std::io::Write;
file.write_all(candidate.as_str().as_bytes()).ok()?;
Some(candidate)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => read_agent_id(&path),
Err(_) => None,
}
}
fn read_agent_id(path: &Path) -> Option<AgentId> {
let raw = std::fs::read_to_string(path).ok()?;
AgentId::parse(raw.trim()).ok()
}
fn generated_id(prefix: &str) -> AgentId {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let token = format!("{prefix}-{:x}-{:x}", std::process::id(), nanos);
AgentId::parse(token).expect("generated id is within the AgentId alphabet")
}
#[derive(Debug, Serialize, Deserialize)]
struct AgentClaim {
agent_id: String,
root: PathBuf,
pid: u32,
updated_unix: i64,
}
fn record_claim(claims_dir: &Path, id: &AgentId, root: &Path) -> Option<IdentityCollision> {
let our_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let path = claims_dir.join(format!(
"{}.json",
crate::hashing::hex(&crate::hashing::hash_bytes(id.as_str().as_bytes()))
));
let previous = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<AgentClaim>(&bytes).ok());
let collision = previous.and_then(|prev| {
(prev.root != our_root).then(|| IdentityCollision {
agent_id: id.as_str().to_string(),
claimed_by_root: prev.root,
claimed_by_pid: prev.pid,
our_root: our_root.clone(),
})
});
let claim = AgentClaim {
agent_id: id.as_str().to_string(),
root: our_root,
pid: std::process::id(),
updated_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default(),
};
if std::fs::create_dir_all(claims_dir).is_ok()
&& let Ok(bytes) = serde_json::to_vec(&claim)
{
let _ = std::fs::write(&path, bytes);
}
collision
}