relayburn_cli/harnesses/
claude.rs1use std::path::PathBuf;
29
30use async_trait::async_trait;
31use relayburn_sdk::{
32 ingest_claude_session, Enrichment, IngestReport, Ledger, LedgerOpenOptions, RawIngestOptions,
33 Stamp, StampSelector,
34};
35
36use super::{HarnessAdapter, PlanCtx, SpawnPlan};
37use crate::util::time::iso_now;
38
39pub struct ClaudeAdapter;
43
44pub static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
47
48fn claude_projects_root() -> PathBuf {
50 let home = std::env::var_os("HOME")
51 .map(PathBuf::from)
52 .unwrap_or_else(|| PathBuf::from("."));
53 home.join(".claude").join("projects")
54}
55
56fn mint_session_id() -> String {
63 use std::collections::hash_map::DefaultHasher;
64 use std::hash::{Hash, Hasher};
65 use std::time::{SystemTime, UNIX_EPOCH};
66
67 let now = SystemTime::now()
68 .duration_since(UNIX_EPOCH)
69 .map(|d| d.as_nanos())
70 .unwrap_or(0);
71 let pid = std::process::id();
72
73 let mut h1 = DefaultHasher::new();
78 now.hash(&mut h1);
79 pid.hash(&mut h1);
80 let lo = h1.finish();
81
82 let mut h2 = DefaultHasher::new();
83 lo.hash(&mut h2);
84 now.wrapping_mul(0x9e37_79b9_7f4a_7c15).hash(&mut h2);
85 let hi = h2.finish();
86
87 let bytes: [u8; 16] = {
88 let mut b = [0u8; 16];
89 b[..8].copy_from_slice(&lo.to_le_bytes());
90 b[8..].copy_from_slice(&hi.to_le_bytes());
91 b[6] = (b[6] & 0x0F) | 0x40;
93 b[8] = (b[8] & 0x3F) | 0x80;
94 b
95 };
96
97 format!(
98 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
99 bytes[0], bytes[1], bytes[2], bytes[3],
100 bytes[4], bytes[5],
101 bytes[6], bytes[7],
102 bytes[8], bytes[9],
103 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
104 )
105}
106
107#[async_trait]
108impl HarnessAdapter for ClaudeAdapter {
109 fn name(&self) -> &'static str {
110 "claude"
111 }
112
113 fn session_root(&self) -> PathBuf {
114 claude_projects_root()
115 }
116
117 async fn plan(&self, ctx: &PlanCtx) -> anyhow::Result<SpawnPlan> {
118 let session_id = mint_session_id();
119 let mut args = vec!["--session-id".to_string(), session_id.clone()];
120 args.extend(ctx.passthrough.iter().cloned());
121 Ok(SpawnPlan {
122 binary: "claude".to_string(),
123 args,
124 env_overrides: vec![("RELAYBURN_SESSION_ID".to_string(), session_id.clone())],
125 session_id: Some(session_id),
126 })
127 }
128
129 async fn before_spawn(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<()> {
130 let session_id = plan
131 .session_id
132 .as_ref()
133 .ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?;
134 write_session_stamp(session_id, &ctx.tags)?;
135 eprintln!("[burn] session-id={session_id}");
136 Ok(())
137 }
138
139 async fn after_exit(&self, ctx: &PlanCtx, plan: &SpawnPlan) -> anyhow::Result<IngestReport> {
140 let session_id = plan
141 .session_id
142 .as_ref()
143 .ok_or_else(|| anyhow::anyhow!("claude adapter: plan must include sessionId"))?;
144 let mut handle = Ledger::open(LedgerOpenOptions::default())?;
149 let cwd_str = ctx.cwd.to_string_lossy().into_owned();
150 let opts = RawIngestOptions::default();
151 ingest_claude_session(handle.raw_mut(), &cwd_str, session_id, &opts).await
152 }
153}
154
155fn write_session_stamp(session_id: &str, enrichment: &Enrichment) -> anyhow::Result<()> {
159 let mut handle = Ledger::open(LedgerOpenOptions::default())?;
160 let selector = StampSelector {
161 session_id: Some(session_id.to_string()),
162 ..Default::default()
163 };
164 let stamp = Stamp::new(iso_now(), selector, enrichment.clone())?;
165 handle.raw_mut().append_stamp(&stamp)?;
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use std::path::PathBuf;
172
173 use super::*;
174
175 #[tokio::test]
176 async fn plan_mints_session_id_and_prepends_session_id_arg() {
177 let ctx = PlanCtx {
178 cwd: PathBuf::from("/tmp"),
179 passthrough: vec!["--resume".to_string(), "abc".to_string()],
180 tags: Enrichment::new(),
181 ledger_home: None,
182 spawn_start_ts: std::time::SystemTime::now(),
183 };
184 let plan = CLAUDE_ADAPTER.plan(&ctx).await.unwrap();
185 assert_eq!(plan.binary, "claude");
186 assert_eq!(plan.args[0], "--session-id");
187 let sid = plan.args.get(1).cloned().unwrap_or_default();
188 assert!(plan.session_id.as_deref() == Some(sid.as_str()));
189 assert_eq!(&plan.args[2..], &["--resume".to_string(), "abc".to_string()]);
190 assert!(plan
192 .env_overrides
193 .iter()
194 .any(|(k, v)| k == "RELAYBURN_SESSION_ID" && v == &sid));
195 }
196
197 #[test]
198 fn name_is_claude_lowercase() {
199 assert_eq!(CLAUDE_ADAPTER.name(), "claude");
200 }
201
202 #[test]
203 fn session_root_lands_under_dot_claude_projects() {
204 let root = CLAUDE_ADAPTER.session_root();
205 let s = root.to_string_lossy();
206 assert!(
207 s.ends_with(".claude/projects") || s.ends_with(".claude\\projects"),
208 "expected session_root under .claude/projects, got {s}"
209 );
210 }
211
212 #[test]
213 fn mint_session_id_round_trips_a_v4_uuid_shape() {
214 let s = mint_session_id();
215 let parts: Vec<&str> = s.split('-').collect();
217 assert_eq!(parts.len(), 5);
218 assert_eq!(parts[0].len(), 8);
219 assert_eq!(parts[1].len(), 4);
220 assert_eq!(parts[2].len(), 4);
221 assert_eq!(parts[3].len(), 4);
222 assert_eq!(parts[4].len(), 12);
223 assert_eq!(&parts[2][..1], "4", "version nibble should be 4 in {s}");
225 let variant_nibble = u8::from_str_radix(&parts[3][..1], 16).unwrap();
227 assert_eq!(variant_nibble & 0xC, 0x8, "variant nibble should be 10xx");
228 }
229
230 #[test]
231 fn iso_now_is_zulu_iso8601() {
232 let s = iso_now();
233 assert_eq!(s.len(), "1970-01-01T00:00:00.000Z".len());
235 assert!(s.ends_with('Z'));
236 assert_eq!(&s[4..5], "-");
237 assert_eq!(&s[7..8], "-");
238 assert_eq!(&s[10..11], "T");
239 assert_eq!(&s[13..14], ":");
240 assert_eq!(&s[16..17], ":");
241 assert_eq!(&s[19..20], ".");
242 }
243}