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, callback_wrapper: nil) ⇒ 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
99
# File 'lib/claude_agent_sdk/session_resume.rb', line 90

def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper: nil)
  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,
    callback_wrapper: callback_wrapper
  )
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.



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
148
149
150
151
152
153
154
155
156
# File 'lib/claude_agent_sdk/session_resume.rb', line 106

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
  # Probed ONCE at materialization entry (the resume path's construction
  # point) so an invalid callback_scheduling declaration fails fast here,
  # before any store IO or temp-dir work.
  scheduling = SessionStores.store_callback_scheduling(store)
  # The session's callback_wrapper composes around every store call below,
  # inside the timeout bound (see FiberBoundary.invoke) — so e.g. Rails'
  # executor.wrap covers an AR-backed adapter during materialization too.
  wrapper = options.callback_wrapper
  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, scheduling, wrapper)
    else
      resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
    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, scheduling, wrapper) 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.



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/claude_agent_sdk/session_resume.rb', line 452

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)


424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/claude_agent_sdk/session_resume.rb', line 424

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, scheduling, wrapper) ⇒ 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.



206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/claude_agent_sdk/session_resume.rb', line 206

def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
  return nil unless SessionStore.implements?(store, :list_session_summaries)

  rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) 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