use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tapes_capture::HarnessSession;
use tracing::warn;
use tapes_capture::envelope::HARNESS_ID_CLAUDE;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeSessionFile {
pub pid: i64,
pub session_id: String,
pub cwd: Option<String>,
pub version: Option<String>,
pub peer_protocol: Option<i64>,
pub kind: Option<String>,
pub entrypoint: Option<String>,
pub name: Option<String>,
pub status: Option<String>,
pub proc_start: Option<String>,
pub started_at: Option<i64>,
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl HarnessSession for ClaudeSessionFile {
fn harness_id(&self) -> &str {
HARNESS_ID_CLAUDE
}
fn session_id(&self) -> &str {
&self.session_id
}
fn version(&self) -> Option<&str> {
self.version.as_deref()
}
fn cwd(&self) -> Option<&str> {
self.cwd.as_deref()
}
fn name(&self) -> Option<&str> {
self.name.as_deref()
}
fn metadata(&self) -> serde_json::Map<String, serde_json::Value> {
let mut metadata = serde_json::Map::new();
if let Some(kind) = &self.kind {
metadata.insert("kind".to_owned(), serde_json::Value::String(kind.clone()));
}
if let Some(entrypoint) = &self.entrypoint {
metadata.insert(
"entrypoint".to_owned(),
serde_json::Value::String(entrypoint.clone()),
);
}
if let Some(pp) = self.peer_protocol {
metadata.insert(
"peerProtocol".to_owned(),
serde_json::Value::Number(pp.into()),
);
}
for (k, v) in &self.extra {
metadata.insert(k.clone(), v.clone());
}
metadata
}
}
pub fn read(dir: &Path, pid: i32) -> Option<ClaudeSessionFile> {
let path = dir.join(format!("{pid}.json"));
let bytes = std::fs::read(&path).ok()?;
match serde_json::from_slice::<ClaudeSessionFile>(&bytes) {
Ok(v) => Some(v),
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"session: could not parse session file",
);
None
}
}
}
#[must_use]
pub fn default_sessions_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".claude").join("sessions"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn read_round_trips_verbatim_fields() {
let dir = tempfile::tempdir().unwrap();
let body = r#"{
"pid": 26716,
"sessionId": "eae77e15-c7d2-4883-b82e-251161f8eeb3",
"cwd": "/Users/matt",
"version": "2.1.145",
"peerProtocol": 1,
"kind": "interactive",
"entrypoint": "cli",
"name": "woo-names-are-fun",
"status": "idle",
"procStart": "Wed May 20 11:11:00 2026",
"startedAt": 1779300649802,
"updatedAt": 1779300681350,
"futureKnob": "preserved-in-extra"
}"#;
std::fs::write(dir.path().join("26716.json"), body).unwrap();
let s = read(dir.path(), 26716).unwrap();
assert_eq!(s.session_id, "eae77e15-c7d2-4883-b82e-251161f8eeb3");
assert_eq!(s.cwd.as_deref(), Some("/Users/matt"));
assert_eq!(s.name.as_deref(), Some("woo-names-are-fun"));
assert!(s.extra.contains_key("futureKnob"));
}
#[test]
fn the_envelope_projection_reads_the_fields_it_names() {
let raw = r#"{
"pid": 26716,
"sessionId": "sid-1",
"cwd": "/Users/matt",
"version": "2.1.145",
"peerProtocol": 1,
"kind": "interactive",
"entrypoint": "cli",
"name": "woo-names",
"futureKnob": "preserved-in-extra"
}"#;
let session: ClaudeSessionFile = serde_json::from_str(raw).unwrap();
assert_eq!(session.harness_id(), HARNESS_ID_CLAUDE);
assert_eq!(session.session_id(), "sid-1");
assert_eq!(session.version(), Some("2.1.145"));
assert_eq!(session.cwd(), Some("/Users/matt"));
assert_eq!(session.name(), Some("woo-names"));
let metadata = session.metadata();
assert_eq!(metadata["kind"], "interactive");
assert_eq!(metadata["entrypoint"], "cli");
assert_eq!(metadata["peerProtocol"], 1);
assert_eq!(metadata["futureKnob"], "preserved-in-extra");
assert!(!metadata.contains_key("sessionId"));
assert!(!metadata.contains_key("cwd"));
}
#[test]
fn a_sparse_session_file_projects_no_optional_fields() {
let session: ClaudeSessionFile =
serde_json::from_str(r#"{"pid": 1, "sessionId": "sid-2"}"#).unwrap();
assert_eq!(session.session_id(), "sid-2");
assert_eq!(session.version(), None);
assert_eq!(session.cwd(), None);
assert_eq!(session.name(), None);
assert!(session.metadata().is_empty());
}
#[test]
fn read_missing_file_is_none() {
let dir = tempfile::tempdir().unwrap();
assert!(read(dir.path(), 9999).is_none());
}
#[test]
fn read_invalid_json_is_none() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("123.json"), "not json").unwrap();
assert!(read(dir.path(), 123).is_none());
}
}