Skip to main content

relayburn_cli/harnesses/
claude.rs

1//! Claude harness adapter — Rust port of
2//! `packages/cli/src/harnesses/claude.ts`.
3//!
4//! Claude is the simplest of the three production harnesses and serves
5//! as the canonical "eager / unit-struct adapter" example for the
6//! [`super::registry::EAGER_ADAPTERS`] tier:
7//!
8//! - **`plan`** mints a fresh session id (UUID v4) and injects it via
9//!   `--session-id`, plus exports `RELAYBURN_SESSION_ID` so any nested
10//!   `burn …` invocation inside the child sees the same id.
11//! - **`before_spawn`** stamps the session up front with the user's
12//!   enrichment tags. The session id is final from the moment the child
13//!   spawns, so we don't need a pending-stamp manifest like
14//!   codex/opencode.
15//! - **`start_watcher`** is left at the default `None`. Claude writes
16//!   exactly one JSONL file per session at `~/.claude/projects/<cwd>/<sid>.jsonl`,
17//!   and the post-exit fast-path
18//!   ([`relayburn_sdk::ingest_claude_session`]) reads it directly. There
19//!   is nothing for a watch loop to drain.
20//! - **`after_exit`** runs the per-session fast-path against the known
21//!   sessionId.
22//!
23//! The adapter itself is a zero-sized unit struct; the static
24//! [`CLAUDE_ADAPTER`] handed to [`super::registry::EAGER_ADAPTERS`] is a
25//! compile-time `&'static dyn HarnessAdapter` reference, so harness
26//! lookup costs nothing at startup.
27
28use 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
39/// Public unit-struct adapter for `claude`. Held as `&'static
40/// CLAUDE_ADAPTER` in the eager `phf::Map` registry — the value `&CLAUDE_ADAPTER`
41/// is a const expression so it satisfies `phf_map!`'s value bound directly.
42pub struct ClaudeAdapter;
43
44/// Static singleton handed to the eager registry. Lifetime: `'static`,
45/// stateless; cloning is unnecessary.
46pub static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
47
48/// Default Claude session-store root: `$HOME/.claude/projects`.
49fn 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
56/// Mint a v4 UUID using the current SystemTime + process id as a weak
57/// entropy source. The harness only needs a stable identifier the
58/// child claude binary will adopt; the SDK validates the shape via
59/// [`relayburn_sdk::is_valid_session_id`] when it stamps. We avoid
60/// pulling in the `uuid` crate just for this one call site — the
61/// formatting matches RFC 4122 (variant + version bits set correctly).
62fn 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    // Two 64-bit hash mixes derived from time + pid. This is "weak
74    // randomness" by cryptographic standards but more than adequate
75    // for picking an unused session id; `claude --session-id` accepts
76    // any UUID-shaped string.
77    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        // RFC 4122 §4.4: set version = 4 (random) and variant = 10xx.
92        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        // Open a ledger handle scoped to the resolved RELAYBURN_HOME and
145        // run the per-session fast-path. The SDK encodes cwd → flattened
146        // dir name internally and persists a cursor at EOF so the next
147        // sweep skips the file.
148        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
155/// Append a session stamp via the SDK ledger. Mirrors the TS sibling's
156/// `await stamp({ sessionId }, ctx.tags)` call, but goes through the
157/// Rust SDK's typed `Stamp::new` + `Ledger::append_stamp` pair.
158fn 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        // Env override carries the same id so a nested `burn …` inherits it.
191        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        // 8-4-4-4-12 hex.
216        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        // Version nibble = 4.
224        assert_eq!(&parts[2][..1], "4", "version nibble should be 4 in {s}");
225        // Variant bits: top two bits of the first nibble of `parts[3]` are 10.
226        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        // Coarse shape: YYYY-MM-DDTHH:MM:SS.mmmZ
234        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}