Module: ClaudeAgentSDK::SessionResume
- Defined in:
- lib/claude_agent_sdk/session_resume.rb
Overview
Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
When resume (or continue_conversation) is paired with a session_store,
the session JSONL usually doesn't exist on local disk — it lives in the
store. The CLI only resumes from a local file. This module loads the session
from the store, writes it to a temp dir laid out like ~/.claude/, and returns
the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
Constant Summary collapse
- KEYCHAIN_SERVICE_NAME =
rubocop:disable Metrics/ModuleLength
'Claude Code-credentials'- KEYCHAIN_TIMEOUT_SECONDS =
5- RETRYABLE_RMTREE_ERRORS =
SystemCallError classes that indicate a transiently-held handle (Windows AV/indexer scanning a freshly-written file) or a recoverable resource shortage (file-table exhaustion) rather than a permanent failure. EMFILE/ ENFILE are treated as transient so the backoff loop can succeed once descriptors free up, matching the Python SDK's retryable errno set.
[ Errno::EBUSY, Errno::ENOTEMPTY, Errno::EPERM, Errno::EACCES, Errno::EMFILE, Errno::ENFILE ].freeze
Class Method Summary collapse
-
.apply_materialized_options(options, materialized) ⇒ Object
Return a copy of
optionsrepointed at a materialized temp config dir: CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and continue_conversation cleared (already resolved to a concrete session id). -
.build_mirror_batcher(store:, env:, on_error:, eager: false) ⇒ Object
Build a TranscriptMirrorBatcher for a configured session_store.
-
.materialize_resume_session(options) ⇒ Object
Load a session from options.session_store and write it to a temp dir.
-
.rmtree_with_retry(path, retries: 4, delay: 0.1) ⇒ Object
Best-effort recursive removal with retries on transient lock errors (Windows AV/indexer).
-
.safe_subpath?(subpath, session_dir) ⇒ Boolean
Reject subpaths that are empty, absolute, drive/UNC-prefixed, contain "." or ".." components or a NUL byte, or escape session_dir after resolution.
-
.sidechain_flags_from_summaries(store, project_key, timeout_s) ⇒ Object
session_id => true for sessions the summary sidecar marks as sidechains; nil when the store doesn't implement list_session_summaries or the call fails (callers then fall back to checking each full load).
Class Method Details
.apply_materialized_options(options, materialized) ⇒ Object
Return a copy of options repointed at a materialized temp config dir:
CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and
continue_conversation cleared (already resolved to a concrete session id).
75 76 77 78 79 80 81 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 75 def (, materialized) .dup_with( env: .env.merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), resume: materialized.resume_session_id, continue_conversation: false ) end |
.build_mirror_batcher(store:, env:, on_error:, eager: false) ⇒ Object
Build a TranscriptMirrorBatcher for a configured session_store. Shared by
both entry points (Client#install_transcript_mirror and the one-shot
query()) so projects_dir resolution and the eager/batched threshold choice
live in one place. env supplies the CLAUDE_CONFIG_DIR override used to
locate the projects dir (already repointed at the temp dir when resuming
from a store). Eager flush mode zeroes the buffer thresholds so every
transcript_mirror frame triggers a background flush.
90 91 92 93 94 95 96 97 98 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 90 def build_mirror_batcher(store:, env:, on_error:, eager: false) TranscriptMirrorBatcher.new( store: store, projects_dir: SessionStores.projects_dir(env), on_error: on_error, max_pending_entries: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_ENTRIES, max_pending_bytes: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_BYTES ) end |
.materialize_resume_session(options) ⇒ Object
Load a session from options.session_store and write it to a temp dir. Returns a MaterializedResume, or nil when no materialization is needed (no store, no resume/continue, store has no entries, or the resolved session id is not a valid UUID) — the caller then falls through to the normal spawn path. Raises RuntimeError if a store call fails or times out.
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 105 def materialize_resume_session() store = .session_store return nil if store.nil? return nil if .resume.nil? && !.continue_conversation timeout_s = .load_timeout_ms / 1000.0 project_key = Sessions.project_key_for_directory(.cwd) resolved = if .resume # session_id is used as a path component below; reject non-UUIDs to # prevent traversal and match every other resume path. return nil unless .resume.match?(Sessions::UUID_RE) load_candidate(store, project_key, .resume, timeout_s) else resolve_continue_candidate(store, project_key, timeout_s) end return nil if resolved.nil? session_id, entries = resolved tmp_base = Dir.mktmpdir('claude-resume-') begin project_dir = File.join(tmp_base, 'projects', project_key) FileUtils.mkdir_p(project_dir) write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), entries) # The subprocess runs with CLAUDE_CONFIG_DIR=tmp_base; copy auth config # so it can authenticate. Missing files are fine (API-key auth, etc.). copy_auth_files(tmp_base, .env) materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) if SessionStore.implements?(store, :list_subkeys) rescue Exception # rubocop:disable Lint/RescueException # Any failure after mkdtemp leaves tmp_base (which may already hold a # .credentials.json copy) on disk with no path for the caller to clean # up. Remove it before re-raising. Rescue Exception (not StandardError) # so reactor stop/cancel also triggers cleanup. rmtree_with_retry(tmp_base) raise end MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id) end |
.rmtree_with_retry(path, retries: 4, delay: 0.1) ⇒ Object
Best-effort recursive removal with retries on transient lock errors (Windows AV/indexer). Never raises. The temp dir holds an access token, so the final sweep matters for not leaking secrets.
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 437 def rmtree_with_retry(path, retries: 4, delay: 0.1) return unless path && File.exist?(path) retries.times do begin FileUtils.remove_entry(path) return rescue Errno::ENOENT return rescue SystemCallError => e break unless RETRYABLE_RMTREE_ERRORS.any? { |klass| e.is_a?(klass) } end sleep(delay) end FileUtils.rm_rf(path) end |
.safe_subpath?(subpath, session_dir) ⇒ Boolean
Reject subpaths that are empty, absolute, drive/UNC-prefixed, contain "." or ".." components or a NUL byte, or escape session_dir after resolution.
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 409 def safe_subpath?(subpath, session_dir) return false if subpath.nil? || subpath.empty? return false if subpath.start_with?('/', '\\') return false if subpath.match?(/\A[a-zA-Z]:/) # drive-prefixed (C:foo) / UNC return false if subpath.split(%r{[\\/]}).any? { |part| ['.', '..'].include?(part) } return false if subpath.include?("\u0000") base = resolve_dir(session_dir) # Join BEFORE expanding: expand_path on a relative first argument performs # tilde expansion, so a store-supplied "~nosuchuser/x" would raise # ArgumentError (and "~root/x" would resolve outside base even though the # literal path the writer uses is contained). The joined path is absolute, # so expand_path only normalizes it. target = File.(File.join(base, "#{subpath}.jsonl")) target == base || target.start_with?("#{base}#{File::SEPARATOR}") rescue ArgumentError false end |
.sidechain_flags_from_summaries(store, project_key, timeout_s) ⇒ Object
session_id => true for sessions the summary sidecar marks as sidechains; nil when the store doesn't implement list_session_summaries or the call fails (callers then fall back to checking each full load). The per-load isSidechain check above stays even on the summary path: a missing or stale sidecar row costs one extra load, never a wrong resume.
197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 197 def sidechain_flags_from_summaries(store, project_key, timeout_s) return nil unless SessionStore.implements?(store, :list_session_summaries) rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do store.list_session_summaries(project_key) end Array(rows).each_with_object({}) do |row, acc| sid = row.is_a?(Hash) ? row['session_id'] : nil acc[sid] = row.dig('data', 'is_sidechain') == true if sid end # NotImplementedError is a ScriptError that with_timeout does not wrap. rescue StandardError, NotImplementedError nil end |