Skip to main content

claude_code_sdk_rust/internal/
session_resume.rs

1use std::path::{Component, Path, PathBuf};
2use std::time::Duration;
3
4use crate::error::{ClaudeSDKError, Result};
5use crate::session_store::{
6    project_key_for_directory, SessionKey, SessionListSubkeysKey, SessionStoreEntry,
7    SessionStoreHandle,
8};
9use crate::types::ClaudeAgentOptions;
10
11#[derive(Debug, Clone)]
12pub struct MaterializedResume {
13    pub config_dir: PathBuf,
14    pub resume_session_id: String,
15}
16
17impl MaterializedResume {
18    pub async fn cleanup(&self) {
19        let _ = tokio::fs::remove_dir_all(&self.config_dir).await;
20    }
21}
22
23pub async fn materialize_resume_session(
24    options: &ClaudeAgentOptions,
25) -> Result<Option<MaterializedResume>> {
26    let Some(store) = options.session_store.clone() else {
27        return Ok(None);
28    };
29    if options.resume.is_none() && !options.continue_conversation {
30        return Ok(None);
31    }
32
33    let project_key = project_key_for_directory(options.cwd.as_deref().map(Path::new));
34    let timeout = Duration::from_millis(options.load_timeout_ms.max(0) as u64);
35    let resolved = if let Some(session_id) = options.resume.as_ref() {
36        if !is_uuid(session_id) {
37            return Ok(None);
38        }
39        load_candidate(&store, &project_key, session_id, timeout).await?
40    } else {
41        resolve_continue_candidate(&store, &project_key, timeout).await?
42    };
43
44    let Some((session_id, entries)) = resolved else {
45        return Ok(None);
46    };
47
48    let config_dir =
49        std::env::temp_dir().join(format!("claude-resume-rust-{}", uuid::Uuid::new_v4()));
50    let project_dir = config_dir.join("projects").join(&project_key);
51    tokio::fs::create_dir_all(&project_dir).await?;
52    if let Err(error) =
53        write_jsonl(&project_dir.join(format!("{session_id}.jsonl")), &entries).await
54    {
55        let _ = tokio::fs::remove_dir_all(&config_dir).await;
56        return Err(error);
57    }
58
59    if let Err(error) =
60        materialize_subkeys(&store, &project_dir, &project_key, &session_id, timeout).await
61    {
62        let _ = tokio::fs::remove_dir_all(&config_dir).await;
63        return Err(error);
64    }
65
66    copy_auth_files(&config_dir, &options.env).await;
67
68    Ok(Some(MaterializedResume {
69        config_dir,
70        resume_session_id: session_id,
71    }))
72}
73
74pub fn apply_materialized_options(
75    options: &ClaudeAgentOptions,
76    materialized: &MaterializedResume,
77) -> ClaudeAgentOptions {
78    let mut options = options.clone();
79    options.env.insert(
80        "CLAUDE_CONFIG_DIR".to_string(),
81        materialized.config_dir.to_string_lossy().to_string(),
82    );
83    options.resume = Some(materialized.resume_session_id.clone());
84    options.continue_conversation = false;
85    options
86}
87
88async fn load_candidate(
89    store: &SessionStoreHandle,
90    project_key: &str,
91    session_id: &str,
92    timeout: Duration,
93) -> Result<Option<(String, Vec<SessionStoreEntry>)>> {
94    let key = SessionKey {
95        project_key: project_key.to_string(),
96        session_id: session_id.to_string(),
97        subpath: None,
98    };
99    let entries = with_timeout(
100        store.load(key),
101        timeout,
102        format!("SessionStore.load() for session {session_id}"),
103    )
104    .await?;
105    Ok(entries
106        .filter(|entries| !entries.is_empty())
107        .map(|entries| (session_id.to_string(), entries)))
108}
109
110async fn resolve_continue_candidate(
111    store: &SessionStoreHandle,
112    project_key: &str,
113    timeout: Duration,
114) -> Result<Option<(String, Vec<SessionStoreEntry>)>> {
115    let mut sessions = with_timeout(
116        store.list_sessions(project_key),
117        timeout,
118        "SessionStore.list_sessions()".to_string(),
119    )
120    .await?;
121    sessions.sort_by_key(|session| std::cmp::Reverse(session.mtime));
122
123    for session in sessions {
124        if !is_uuid(&session.session_id) {
125            continue;
126        }
127        let Some((session_id, entries)) =
128            load_candidate(store, project_key, &session.session_id, timeout).await?
129        else {
130            continue;
131        };
132        if entries
133            .first()
134            .and_then(|entry| entry.get("isSidechain"))
135            .and_then(|value| value.as_bool())
136            == Some(true)
137        {
138            continue;
139        }
140        return Ok(Some((session_id, entries)));
141    }
142
143    Ok(None)
144}
145
146async fn materialize_subkeys(
147    store: &SessionStoreHandle,
148    project_dir: &Path,
149    project_key: &str,
150    session_id: &str,
151    timeout: Duration,
152) -> Result<()> {
153    let subkeys = store
154        .list_subkeys(SessionListSubkeysKey {
155            project_key: project_key.to_string(),
156            session_id: session_id.to_string(),
157        })
158        .await
159        .unwrap_or_default();
160    let session_dir = project_dir.join(session_id);
161
162    for subpath in subkeys {
163        if !is_safe_subpath(&subpath) {
164            continue;
165        }
166        let key = SessionKey {
167            project_key: project_key.to_string(),
168            session_id: session_id.to_string(),
169            subpath: Some(subpath.clone()),
170        };
171        let Some(entries) = with_timeout(
172            store.load(key),
173            timeout,
174            format!("SessionStore.load() for session {session_id} subpath {subpath}"),
175        )
176        .await?
177        else {
178            continue;
179        };
180        if entries.is_empty() {
181            continue;
182        }
183
184        write_subpath_entries(&session_dir, &subpath, &entries).await?;
185    }
186    Ok(())
187}
188
189async fn write_subpath_entries(
190    session_dir: &Path,
191    subpath: &str,
192    entries: &[SessionStoreEntry],
193) -> Result<()> {
194    let mut transcript = Vec::new();
195    let mut metadata = None;
196    for entry in entries {
197        if entry.get("type").and_then(|value| value.as_str()) == Some("agent_metadata") {
198            let mut metadata_entry = entry.clone();
199            metadata_entry.remove("type");
200            metadata = Some(metadata_entry);
201        } else {
202            transcript.push(entry.clone());
203        }
204    }
205
206    let base_path = session_dir.join(subpath);
207    let transcript_path = base_path.with_extension("jsonl");
208    if !transcript.is_empty() {
209        write_jsonl(&transcript_path, &transcript).await?;
210    }
211    if let Some(metadata) = metadata {
212        let metadata_path = base_path.with_extension("meta.json");
213        if let Some(parent) = metadata_path.parent() {
214            tokio::fs::create_dir_all(parent).await?;
215        }
216        tokio::fs::write(&metadata_path, serde_json::to_vec(&metadata)?).await?;
217    }
218    Ok(())
219}
220
221async fn write_jsonl(path: &Path, entries: &[SessionStoreEntry]) -> Result<()> {
222    if let Some(parent) = path.parent() {
223        tokio::fs::create_dir_all(parent).await?;
224    }
225    let mut out = Vec::new();
226    for entry in entries {
227        out.extend(serde_json::to_vec(entry)?);
228        out.push(b'\n');
229    }
230    tokio::fs::write(path, out).await?;
231    Ok(())
232}
233
234async fn with_timeout<F, T>(future: F, timeout: Duration, label: String) -> Result<T>
235where
236    F: std::future::Future<Output = Result<T>>,
237{
238    match tokio::time::timeout(timeout, future).await {
239        Ok(result) => result.map_err(|error| {
240            ClaudeSDKError::Session(format!(
241                "{label} failed during resume materialization: {error}"
242            ))
243        }),
244        Err(_) => Err(ClaudeSDKError::Session(format!(
245            "{label} timed out after {}ms during resume materialization",
246            timeout.as_millis()
247        ))),
248    }
249}
250
251async fn copy_auth_files(config_dir: &Path, env: &std::collections::HashMap<String, String>) {
252    let source_config_dir = env
253        .get("CLAUDE_CONFIG_DIR")
254        .map(PathBuf::from)
255        .or_else(|| std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from))
256        .or_else(|| dirs::home_dir().map(|home| home.join(".claude")));
257
258    if let Some(source_config_dir) = source_config_dir {
259        copy_redacted_credentials(
260            &source_config_dir.join(".credentials.json"),
261            &config_dir.join(".credentials.json"),
262        )
263        .await;
264        let claude_json = if env.contains_key("CLAUDE_CONFIG_DIR")
265            || std::env::var_os("CLAUDE_CONFIG_DIR").is_some()
266        {
267            source_config_dir.join(".claude.json")
268        } else {
269            dirs::home_dir()
270                .map(|home| home.join(".claude.json"))
271                .unwrap_or_else(|| PathBuf::from(".claude.json"))
272        };
273        copy_if_present(&claude_json, &config_dir.join(".claude.json")).await;
274    }
275}
276
277async fn copy_redacted_credentials(src: &Path, dst: &Path) {
278    let Ok(content) = tokio::fs::read_to_string(src).await else {
279        return;
280    };
281    let redacted = redact_refresh_token(&content).unwrap_or(content);
282    let _ = tokio::fs::write(dst, redacted).await;
283}
284
285fn redact_refresh_token(content: &str) -> Option<String> {
286    let mut value = serde_json::from_str::<serde_json::Value>(content).ok()?;
287    value
288        .get_mut("claudeAiOauth")?
289        .as_object_mut()?
290        .remove("refreshToken");
291    serde_json::to_string(&value).ok()
292}
293
294async fn copy_if_present(src: &Path, dst: &Path) {
295    if let Ok(content) = tokio::fs::read(src).await {
296        let _ = tokio::fs::write(dst, content).await;
297    }
298}
299
300fn is_uuid(value: &str) -> bool {
301    uuid::Uuid::parse_str(value).is_ok()
302}
303
304pub(crate) fn is_safe_subpath(subpath: &str) -> bool {
305    if subpath.is_empty()
306        || subpath.starts_with('/')
307        || subpath.starts_with('\\')
308        || subpath.contains('\0')
309        || subpath.contains(':')
310    {
311        return false;
312    }
313    let path = Path::new(subpath);
314    !path.is_absolute()
315        && path
316            .components()
317            .all(|component| matches!(component, Component::Normal(_)))
318}