use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::agent::{Agent, Continue, SessionSupport};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SessionRecord {
pub name: String,
pub project: String,
pub agent: Agent,
pub token: String,
pub created: i64,
pub updated: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Create,
Continue,
Fork,
}
#[derive(Debug, Clone)]
pub struct SessionStore {
dir: PathBuf,
}
impl SessionStore {
pub fn open(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
#[must_use]
pub fn default_dir() -> Option<PathBuf> {
let base = if cfg!(windows) {
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("state"))
})
};
Some(base?.join("agent-abstraction").join("sessions"))
}
#[must_use]
pub fn path_of(&self, project: &Path, name: &str) -> PathBuf {
self.dir
.join(project_slug(project))
.join(format!("{}.json", encode_segment(name)))
}
pub fn get(&self, project: &Path, name: &str) -> Result<Option<SessionRecord>> {
let path = self.path_of(project, name);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(Error::Store {
path: path.display().to_string(),
source,
});
}
};
serde_json::from_str(&text)
.map(Some)
.map_err(|e| Error::Store {
path: path.display().to_string(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})
}
pub fn list(&self, project: &Path) -> Result<Vec<SessionRecord>> {
let dir = self.dir.join(project_slug(project));
let store_err = |path: &Path, source| Error::Store {
path: path.display().to_string(),
source,
};
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(store_err(&dir, e)),
};
let mut out = Vec::new();
for entry in entries {
let path = entry.map_err(|e| store_err(&dir, e))?.path();
if path.extension().is_some_and(|ext| ext == "tmp") {
continue;
}
let text = fs::read_to_string(&path).map_err(|e| store_err(&path, e))?;
out.push(serde_json::from_str(&text).map_err(|e| {
store_err(
&path,
std::io::Error::new(std::io::ErrorKind::InvalidData, e),
)
})?);
}
Ok(out)
}
#[must_use]
pub fn list_lossy(&self, project: &Path) -> Vec<SessionRecord> {
let dir = self.dir.join(project_slug(project));
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|e| fs::read_to_string(e.path()).ok())
.filter_map(|text| serde_json::from_str(&text).ok())
.collect()
}
pub(crate) fn plan(
&self,
agent: Agent,
project: &Path,
name: &str,
fork: bool,
) -> Result<(Phase, Continue)> {
let caps = agent.caps();
if caps.session == SessionSupport::None {
return Err(Error::Unsupported {
agent,
what: "named sessions (it exposes no session id headlessly)",
});
}
let existing = self.get(project, name)?;
if let Some(record) = &existing {
if record.agent != agent {
return Err(Error::SessionConflict {
name: name.to_string(),
bound: record.agent,
requested: agent,
});
}
}
Ok(match (existing, fork) {
(Some(record), true) => {
if !caps.fork {
return Err(Error::Unsupported {
agent,
what: "forking a session headlessly",
});
}
(Phase::Fork, Continue::Fork(record.token))
}
(Some(record), false) => (Phase::Continue, Continue::Resume(record.token)),
(None, _) => (
Phase::Create,
match caps.session {
SessionSupport::Minted => Continue::NewWith(Uuid::new_v4().to_string()),
SessionSupport::Printed | SessionSupport::None => Continue::New,
},
),
})
}
pub fn bind(
&self,
agent: Agent,
project: &Path,
name: &str,
token: &str,
) -> Result<SessionRecord> {
if let Some(existing) = self.get(project, name)?
&& existing.agent != agent
{
return Err(Error::SessionConflict {
name: name.to_string(),
bound: existing.agent,
requested: agent,
});
}
let now = now_secs();
let record = SessionRecord {
name: name.to_string(),
project: project.display().to_string(),
agent,
token: token.to_string(),
created: self.get(project, name)?.map_or(now, |r| r.created),
updated: now,
};
let path = self.path_of(project, name);
let store_err = |source| Error::Store {
path: path.display().to_string(),
source,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(store_err)?;
restrict_to_owner(parent).map_err(store_err)?;
}
let mut text = serde_json::to_string_pretty(&record)
.map_err(|e| store_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
text.push('\n');
let tmp = path.with_extension(format!("{}.{}.tmp", std::process::id(), next_temp_id()));
write_private(&tmp, text.as_bytes()).map_err(store_err)?;
fs::rename(&tmp, &path).map_err(|e| {
let _ = fs::remove_file(&tmp);
store_err(e)
})?;
if let Some(parent) = path.parent() {
sync_dir(parent).map_err(store_err)?;
}
Ok(record)
}
pub fn forget(&self, project: &Path, name: &str) -> Result<()> {
let path = self.path_of(project, name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(Error::Store {
path: path.display().to_string(),
source,
}),
}
}
}
fn next_temp_id() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
COUNTER.fetch_add(1, Ordering::Relaxed)
}
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
fn sync_dir(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
fs::File::open(dir)?.sync_all()?;
}
#[cfg(not(unix))]
let _ = dir;
Ok(())
}
fn restrict_to_owner(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
#[cfg(not(unix))]
let _ = dir;
Ok(())
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
const MAX_STEM: usize = 200;
fn encode_segment(name: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
{
out.push(byte as char);
} else {
let _ = write!(out, "%{byte:02X}");
}
}
if out.is_empty() {
return "%".into();
}
if out.len() > MAX_STEM {
let mut cut = MAX_STEM;
while cut > 0 && !is_encoding_boundary(&out, cut) {
cut -= 1;
}
return format!("{}-{:016x}", &out[..cut], fnv1a(name.as_bytes()));
}
out
}
fn is_encoding_boundary(s: &str, at: usize) -> bool {
let b = s.as_bytes();
!((at >= 1 && b[at - 1] == b'%') || (at >= 2 && b[at - 2] == b'%'))
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn project_slug(project: &Path) -> String {
encode_segment(&project.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn store(tag: &str) -> (SessionStore, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"agent-abstraction-{tag}-{}-{}",
std::process::id(),
now_secs()
));
(SessionStore::open(dir), PathBuf::from("/home/me/proj"))
}
#[test]
fn names_and_projects_reduce_to_one_safe_segment() {
assert_eq!(encode_segment("greet-flow"), "greet-flow");
assert_eq!(encode_segment("v1.2_final"), "v1.2_final");
assert_eq!(encode_segment(""), "%");
assert_ne!(encode_segment(""), encode_segment("unnamed"));
for name in ["../../etc/passwd", "..", ".", "a/b", "a\\b"] {
let encoded = encode_segment(name);
assert!(!encoded.contains('/'), "{name:?} kept a separator");
assert!(!encoded.contains('\\'), "{name:?} kept a separator");
assert!(
Path::new(&encoded).components().count() == 1,
"{name:?} encoded to more than one component"
);
}
assert!(!project_slug(Path::new("/home/me/My Proj")).contains('/'));
}
#[test]
fn distinct_names_never_share_an_encoded_segment() {
let names = [
"café",
"cafe-",
"cafe",
"Chat",
"chat",
"CHAT",
"a/b",
"a-b",
"a b",
"..",
"%41",
"A",
"",
"unnamed",
"日本語",
"🙂",
];
let mut seen = std::collections::HashMap::new();
for name in names {
let key = encode_segment(name).to_ascii_lowercase();
if let Some(previous) = seen.insert(key.clone(), name) {
panic!("{name:?} and {previous:?} both encode to {key:?}");
}
}
}
#[test]
fn a_very_long_name_stays_within_filename_limits_and_stays_unique() {
let a = "x".repeat(5_000);
let b = format!("{a}different");
let (ea, eb) = (encode_segment(&a), encode_segment(&b));
assert!(ea.len() < 250, "{}", ea.len());
assert!(eb.len() < 250);
assert_ne!(ea, eb, "truncation must not collapse distinct names");
}
#[test]
fn truncation_never_splits_an_escape_sequence() {
let encoded = encode_segment(&"A".repeat(2_000));
let stem = encoded.rsplit_once('-').unwrap().0;
for (i, _) in stem.match_indices('%') {
assert!(i + 2 < stem.len(), "escape split at {i} in {stem:?}");
}
}
#[test]
fn the_record_preserves_the_original_name() {
let (store, project) = store("original-name");
store
.bind(Agent::Claude, &project, "Greet Flow ☕", "t-1")
.unwrap();
let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap();
assert_eq!(record.name, "Greet Flow ☕");
assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕");
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn a_path_traversing_name_cannot_escape_the_store() {
let (store, project) = store("escape");
for name in ["../../etc/passwd", "..", "/etc/passwd", "a/../../b"] {
let path = store.path_of(&project, name);
assert!(path.starts_with(&store.dir), "{name:?} escaped to {path:?}");
assert_eq!(
path.strip_prefix(&store.dir).unwrap().components().count(),
2,
"{name:?} produced extra path components: {path:?}"
);
}
}
#[test]
fn a_missing_session_plans_a_create() {
let (store, project) = store("create");
let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
assert_eq!(phase, Phase::Create);
let Continue::NewWith(id) = cont else {
panic!("a minting agent must allocate an id up front, got {cont:?}")
};
assert!(Uuid::parse_str(&id).is_ok(), "{id} must be a UUID");
}
#[test]
fn a_printing_agent_starts_without_an_id() {
let (store, project) = store("printed");
let (phase, cont) = store.plan(Agent::Codex, &project, "chat", false).unwrap();
assert_eq!(phase, Phase::Create);
assert_eq!(cont, Continue::New, "codex's id only exists once printed");
}
#[test]
fn a_bound_session_plans_a_continue_and_survives_a_round_trip() {
let (store, project) = store("continue");
store
.bind(Agent::Claude, &project, "chat", "sess-1")
.unwrap();
let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
assert_eq!(phase, Phase::Continue);
assert_eq!(cont, Continue::Resume("sess-1".into()));
let record = store.get(&project, "chat").unwrap().unwrap();
assert_eq!(record.token, "sess-1");
assert_eq!(record.agent, Agent::Claude);
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn rebinding_refreshes_the_token_but_keeps_the_creation_time() {
let (store, project) = store("rebind");
let first = store
.bind(Agent::Claude, &project, "chat", "sess-1")
.unwrap();
let second = store
.bind(Agent::Claude, &project, "chat", "sess-2")
.unwrap();
assert_eq!(second.token, "sess-2");
assert_eq!(second.created, first.created);
assert!(second.updated >= first.updated);
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn a_session_cannot_migrate_between_agents() {
let (store, project) = store("conflict");
store
.bind(Agent::Claude, &project, "chat", "sess-1")
.unwrap();
let err = store
.plan(Agent::Codex, &project, "chat", false)
.unwrap_err();
assert!(
matches!(err, Error::SessionConflict { bound, requested, .. }
if bound == Agent::Claude && requested == Agent::Codex),
"got {err:?}"
);
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn forking_is_refused_by_agents_that_cannot_fork() {
let (store, project) = store("fork");
store.bind(Agent::Codex, &project, "chat", "t-1").unwrap();
assert!(matches!(
store.plan(Agent::Codex, &project, "chat", true),
Err(Error::Unsupported { .. })
));
store.bind(Agent::Claude, &project, "c2", "sess-1").unwrap();
let (phase, cont) = store.plan(Agent::Claude, &project, "c2", true).unwrap();
assert_eq!(phase, Phase::Fork);
assert_eq!(cont, Continue::Fork("sess-1".into()));
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn forking_a_session_that_does_not_exist_yet_just_creates_one() {
let (store, project) = store("fork-new");
let (phase, _) = store.plan(Agent::Claude, &project, "fresh", true).unwrap();
assert_eq!(phase, Phase::Create, "nothing to branch from yet");
}
#[test]
fn a_corrupt_record_is_reported_rather_than_silently_ignored() {
let (store, project) = store("corrupt");
let path = store.path_of(&project, "chat");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"{ not json").unwrap();
assert!(matches!(
store.get(&project, "chat"),
Err(Error::Store { .. })
));
assert!(matches!(
store.plan(Agent::Claude, &project, "chat", false),
Err(Error::Store { .. })
));
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn sessions_list_per_project_and_forgetting_is_idempotent() {
let (store, project) = store("list");
store.bind(Agent::Claude, &project, "a", "t-a").unwrap();
store.bind(Agent::Claude, &project, "b", "t-b").unwrap();
let mut names: Vec<_> = store
.list(&project)
.unwrap()
.into_iter()
.map(|r| r.name)
.collect();
names.sort();
assert_eq!(names, ["a", "b"]);
store.forget(&project, "a").unwrap();
assert!(store.get(&project, "a").unwrap().is_none());
store.forget(&project, "a").unwrap();
assert_eq!(store.list(&project).unwrap().len(), 1);
fs::remove_dir_all(&store.dir).ok();
}
#[test]
fn the_same_name_in_two_projects_does_not_collide() {
let (store, project) = store("projects");
let other = PathBuf::from("/home/me/other");
store.bind(Agent::Claude, &project, "chat", "t-1").unwrap();
store.bind(Agent::Claude, &other, "chat", "t-2").unwrap();
assert_eq!(store.get(&project, "chat").unwrap().unwrap().token, "t-1");
assert_eq!(store.get(&other, "chat").unwrap().unwrap().token, "t-2");
fs::remove_dir_all(&store.dir).ok();
}
}