use std::path::PathBuf;
use async_trait::async_trait;
use relayburn_sdk::{
ingest_claude_session, Enrichment, IngestReport, Ledger, LedgerOpenOptions, RawIngestOptions,
Stamp, StampSelector,
};
use super::{HarnessAdapter, PlanCtx, SpawnPlan};
use crate::util::time::iso_now;
pub struct ClaudeAdapter;
pub static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
fn claude_projects_root() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".claude").join("projects")
}
fn mint_session_id() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let mut h1 = DefaultHasher::new();
now.hash(&mut h1);
pid.hash(&mut h1);
let lo = h1.finish();
let mut h2 = DefaultHasher::new();
lo.hash(&mut h2);
now.wrapping_mul(0x9e37_79b9_7f4a_7c15).hash(&mut h2);
let hi = h2.finish();
let bytes: [u8; 16] = {
let mut b = [0u8; 16];
b[..8].copy_from_slice(&lo.to_le_bytes());
b[8..].copy_from_slice(&hi.to_le_bytes());
b[6] = (b[6] & 0x0F) | 0x40;
b[8] = (b[8] & 0x3F) | 0x80;
b
};
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
#[async_trait]
impl HarnessAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"claude"
}
fn session_root(&self) -> PathBuf {
claude_projects_root()
}
async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result<SpawnPlan> {
let session_id = mint_session_id();
let mut args = vec!["--session-id".to_string(), session_id.clone()];
args.extend(ctx.passthrough.iter().cloned());
Ok(SpawnPlan {
binary: "claude".to_string(),
args,
env_overrides: vec![("RELAYBURN_SESSION_ID".to_string(), session_id.clone())],
session_id: Some(session_id),
})
}
async fn before_spawn(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<()> {
let session_id = plan
.session_id
.as_ref()
.ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?;
write_session_stamp(session_id, &ctx.tags)?;
eprintln!("[burn] session-id={session_id}");
Ok(())
}
async fn after_exit(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<IngestReport> {
let session_id = plan
.session_id
.as_ref()
.ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?;
let mut handle = Ledger::open(LedgerOpenOptions::default())?;
let cwd_str = ctx.cwd.to_string_lossy().into_owned();
let opts = RawIngestOptions::default();
ingest_claude_session(handle.raw_mut(), &cwd_str, session_id, &opts).await
}
}
fn write_session_stamp(session_id: &str, enrichment: &Enrichment) -> anyhow::Result<()> {
let mut handle = Ledger::open(LedgerOpenOptions::default())?;
let selector = StampSelector {
session_id: Some(session_id.to_string()),
..Default::default()
};
let stamp = Stamp::new(iso_now(), selector, enrichment.clone())?;
handle.raw_mut().append_stamp(&stamp)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[tokio::test]
async fn plan_mints_session_id_and_prepends_session_id_arg() {
let ctx = PlanCtx {
cwd: PathBuf::from("/tmp"),
passthrough: vec!["--resume".to_string(), "abc".to_string()],
tags: Enrichment::new(),
ledger_home: None,
spawn_start_ts: std::time::SystemTime::now(),
};
let plan = CLAUDE_ADAPTER.plan(&ctx).await.unwrap();
assert_eq!(plan.binary, "claude");
assert_eq!(plan.args[0], "--session-id");
let sid = plan.args.get(1).cloned().unwrap_or_default();
assert!(plan.session_id.as_deref() == Some(sid.as_str()));
assert_eq!(&plan.args[2..], &["--resume".to_string(), "abc".to_string()]);
assert!(plan
.env_overrides
.iter()
.any(|(k, v)| k == "RELAYBURN_SESSION_ID" && v == &sid));
}
#[test]
fn name_is_claude_lowercase() {
assert_eq!(CLAUDE_ADAPTER.name(), "claude");
}
#[test]
fn session_root_lands_under_dot_claude_projects() {
let root = CLAUDE_ADAPTER.session_root();
let s = root.to_string_lossy();
assert!(
s.ends_with(".claude/projects") || s.ends_with(".claude\\projects"),
"expected session_root under .claude/projects, got {s}"
);
}
#[test]
fn mint_session_id_round_trips_a_v4_uuid_shape() {
let s = mint_session_id();
let parts: Vec<&str> = s.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[0].len(), 8);
assert_eq!(parts[1].len(), 4);
assert_eq!(parts[2].len(), 4);
assert_eq!(parts[3].len(), 4);
assert_eq!(parts[4].len(), 12);
assert_eq!(&parts[2][..1], "4", "version nibble should be 4 in {s}");
let variant_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap();
assert_eq!(variant_nibble & 0xC, 0x8, "variant nibble should be 10xx");
}
#[test]
fn iso_now_is_zulu_iso8601() {
let s = iso_now();
assert_eq!(s.len(), "1970-01-01T00:00:00.000Z".len());
assert!(s.ends_with('Z'));
assert_eq!(&s[4..5], "-");
assert_eq!(&s[7..8], "-");
assert_eq!(&s[10..11], "T");
assert_eq!(&s[13..14], ":");
assert_eq!(&s[16..17], ":");
assert_eq!(&s[19..20], ".");
}
}