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