Module: ClaudeAgentSDK::SessionStores

Defined in:
lib/claude_agent_sdk/session_store.rb

Overview

Internal SessionStore support functions (path mapping, option validation).

Class Method Summary collapse

Class Method Details

.file_path_to_session_key(file_path, projects_dir) ⇒ Object

Derive a SessionKey from an absolute transcript file path.

Main:     <projects_dir>/<project_key>/<session_id>.jsonl
Subagent: <projects_dir>/<project_key>/<session_id>/subagents/agent-<id>.jsonl

Returns nil if file_path is not under projects_dir or has an unrecognized shape.



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/claude_agent_sdk/session_store.rb', line 261

def file_path_to_session_key(file_path, projects_dir)
  # A frame with a missing/non-String filePath would make Pathname.new raise
  # TypeError (not the ArgumentError handled below), which propagates out of
  # do_flush and drops the entire coalesced drain batch. Treat it as
  # "not under projects_dir" so only the bad frame is skipped.
  return nil unless file_path.is_a?(String) && !file_path.empty?

  begin
    rel = Pathname.new(file_path).relative_path_from(Pathname.new(projects_dir)).to_s
  rescue ArgumentError
    # Different drives on Windows — treat as "not under projects_dir".
    return nil
  end

  parts = rel.split('/')
  # Reject paths that escape projects_dir: a leading ".." *segment* (exact
  # match, so a legitimate dir like "..foo" still maps), the "." self-ref,
  # or an absolute path. Comparing parts[0] rather than rel.start_with?("..")
  # avoids the "..foo" false positive that would silently drop valid frames.
  return nil if parts.empty? || parts[0] == '..' || rel == '.' || Pathname.new(rel).absolute?
  return nil if parts.length < 2

  project_key = parts[0]
  second = parts[1]

  # Main transcript: <project_key>/<session_id>.jsonl
  return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')

  # Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
  if parts.length >= 4
    subpath_parts = parts[2..]
    subpath_parts[-1] = subpath_parts[-1].delete_suffix('.jsonl')
    # Subpaths are always /-joined so keys are portable across platforms.
    return { 'project_key' => project_key, 'session_id' => second, 'subpath' => subpath_parts.join('/') }
  end

  nil
end

.projects_dir(env_override = nil) ⇒ Object

Path to the rel-from base where session transcripts live, honoring a CLAUDE_CONFIG_DIR override passed to the subprocess via options.env. Mirrors Sessions#config_dir but consults an explicit env override first.

Presence is detected by KEY, not value: the transport treats an explicit nil value as “unset the var for the child”, so the CLI then writes under the default ~/.claude — not under the parent’s CLAUDE_CONFIG_DIR. Empty strings get the same treatment (the Node CLI treats “” as unset).



342
343
344
345
346
347
348
349
350
351
# File 'lib/claude_agent_sdk/session_store.rb', line 342

def projects_dir(env_override = nil)
  if env_override.respond_to?(:key?) &&
     (env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
    override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
    override = nil if override.respond_to?(:empty?) && override.empty?
    return File.join(override || File.expand_path('~/.claude'), 'projects')
  end

  File.join(Sessions.config_dir, 'projects')
end

.validate_session_store_options(options) ⇒ Object

Raise ArgumentError for invalid session_store option combinations. Called before subprocess spawn so misconfiguration fails fast.

Raises:

  • (ArgumentError)


302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/claude_agent_sdk/session_store.rb', line 302

def validate_session_store_options(options)
  store = options.session_store
  return if store.nil?

  # #append/#load are required, but a subclass inheriting the base stubs
  # would only fail at first use — with NotImplementedError, a ScriptError
  # that rescue StandardError layers don't catch. Fail fast here instead.
  %i[append load].each do |method|
    raise ArgumentError, "session_store must implement ##{method}" unless SessionStore.implements?(store, method)
  end

  flush = options.session_store_flush.to_s
  unless SESSION_STORE_FLUSH_MODES.include?(flush)
    raise ArgumentError,
          "invalid session_store_flush: #{options.session_store_flush.inspect} " \
          "(expected one of #{SESSION_STORE_FLUSH_MODES.join(', ')})"
  end

  # When resume is explicitly set, list_sessions is provably never called
  # (resume wins over continue), so a minimal store is fine.
  if options.continue_conversation && options.resume.nil? && !SessionStore.implements?(store, :list_sessions)
    raise ArgumentError,
          'continue_conversation with session_store requires the store to implement #list_sessions'
  end

  return unless options.enable_file_checkpointing

  raise ArgumentError,
        'session_store cannot be combined with enable_file_checkpointing ' \
        '(checkpoints are local-disk only and would diverge from the mirrored transcript)'
end