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

SEEDED_SETTINGS_FILES =

User settings files seeded into the temp config dir. cowork_settings.json is the alternate filename the CLI reads in cowork-plugins mode.

['settings.json', 'cowork_settings.json'].freeze
RESUME_SETTINGS_STRIPPED_KEYS =

User-settings keys that only misbehave under the redirected CLAUDE_CONFIG_DIR: plugin declarations reconcile against the always-empty tmp_base/plugins cache and would network-install each declared marketplace on every resume.

%w[enabledPlugins extraKnownMarketplaces].freeze
UTF8_BOM =
"\xEF\xBB\xBF".b.freeze
KEYCHAIN_SERVICE_NAME =
'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).



88
89
90
91
92
93
94
# File 'lib/claude_agent_sdk/session_resume.rb', line 88

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.



103
104
105
106
107
108
109
110
111
112
# File 'lib/claude_agent_sdk/session_resume.rb', line 103

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.



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
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/claude_agent_sdk/session_resume.rb', line 119

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.



610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
# File 'lib/claude_agent_sdk/session_resume.rb', line 610

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)


582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# File 'lib/claude_agent_sdk/session_resume.rb', line 582

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.



219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/claude_agent_sdk/session_resume.rb', line 219

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