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::home::home_dir;
38use crate::util::time::iso_now;
39
40/// Public unit-struct adapter for `claude`. Held as `&'static
41/// CLAUDE_ADAPTER` in the eager `phf::Map` registry — the value `&CLAUDE_ADAPTER`
42/// is a const expression so it satisfies `phf_map!`'s value bound directly.
43pub struct ClaudeAdapter;
44
45/// Static singleton handed to the eager registry. Lifetime: `'static`,
46/// stateless; cloning is unnecessary.
47pub static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
48
49/// Default Claude session-store root: `$HOME/.claude/projects`.
50fn claude_projects_root() -> PathBuf {
51    home_dir().join(".claude").join("projects")
52}
53
54/// Mint a v4 UUID using the current SystemTime + process id as a weak
55/// entropy source. The harness only needs a stable identifier the
56/// child claude binary will adopt; the SDK validates the shape via
57/// [`relayburn_sdk::is_valid_session_id`] when it stamps. We avoid
58/// pulling in the `uuid` crate just for this one call site — the
59/// formatting matches RFC 4122 (variant + version bits set correctly).
60fn 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    // Two 64-bit hash mixes derived from time + pid. This is "weak
72    // randomness" by cryptographic standards but more than adequate
73    // for picking an unused session id; `claude --session-id` accepts
74    // any UUID-shaped string.
75    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        // RFC 4122 §4.4: set version = 4 (random) and variant = 10xx.
90        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        // Open a ledger handle scoped to the resolved RELAYBURN_HOME and
143        // run the per-session fast-path. The SDK encodes cwd → flattened
144        // dir name internally and persists a cursor at EOF so the next
145        // sweep skips the file.
146        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
153/// Append a session stamp via the SDK ledger. Mirrors the TS sibling's
154/// `await stamp({ sessionId }, ctx.tags)` call, but goes through the
155/// Rust SDK's typed `Stamp::new` + `Ledger::append_stamp` pair.
156fn 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        // Env override carries the same id so a nested `burn …` inherits it.
189        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        // 8-4-4-4-12 hex.
214        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        // Version nibble = 4.
222        assert_eq!(&parts[2][..1], "4", "version nibble should be 4 in {s}");
223        // Variant bits: top two bits of the first nibble of `parts[3]` are 10.
224        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        // Coarse shape: YYYY-MM-DDTHH:MM:SS.mmmZ
232        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}