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).



58
59
60
61
62
63
64
# File 'lib/claude_agent_sdk/session_resume.rb', line 58

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.



73
74
75
76
77
78
79
80
81
# File 'lib/claude_agent_sdk/session_resume.rb', line 73

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.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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
# File 'lib/claude_agent_sdk/session_resume.rb', line 88

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.



378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/claude_agent_sdk/session_resume.rb', line 378

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)


350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/claude_agent_sdk/session_resume.rb', line 350

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