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

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 apply_materialized_options(options, materialized)
  options.dup_with(
    env: options.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(options)
  store = options.session_store
  return nil if store.nil?
  return nil if options.resume.nil? && !options.continue_conversation

  timeout_s = options.load_timeout_ms / 1000.0
  project_key = Sessions.project_key_for_directory(options.cwd)

  resolved =
    if options.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 options.resume.match?(Sessions::UUID_RE)

      load_candidate(store, project_key, options.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, options.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.

Returns:

  • (Boolean)


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.expand_path(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