Module: ClaudeAgentSDK::Sessions

Defined in:
lib/claude_agent_sdk/sessions.rb

Overview

Session browsing functions

Constant Summary collapse

LITE_READ_BUF_SIZE =

rubocop:disable Metrics/ModuleLength

65_536
MAX_SANITIZED_LENGTH =
200
UUID_RE =
/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
TRANSCRIPT_ENTRY_TYPES =

Transcript entry types that participate in conversation reads. One shared constant for the disk (parse_jsonl_entries) and store (filter_transcript_entries) paths so the two read paths can’t drift when the CLI adds a new entry type (mirrors Python’s _TRANSCRIPT_ENTRY_TYPES).

%w[user assistant progress system attachment].freeze
SKIP_FIRST_PROMPT_PATTERN =
%r{\A(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|
\[Request\ interrupted\ by\ user[^\]]*\]|
\s*<ide_opened_file>[\s\S]*</ide_opened_file>\s*\z|
\s*<ide_selection>[\s\S]*</ide_selection>\s*\z)}x
COMMAND_NAME_RE =
%r{<command-name>(.*?)</command-name>}
SANITIZE_RE =
/[^a-zA-Z0-9]/

Class Method Summary collapse

Class Method Details

.canonicalize_path(dir) ⇒ Object

Resolve a directory to its canonical form (realpath + NFC), matching the CLI’s project-directory naming. Falls back to an absolute NFC path when realpath can’t resolve it (e.g. the directory does not exist yet) — Ruby’s File.realpath raises on missing paths whereas Python’s os.path.realpath is lexical for the missing suffix, so expand_path restores that behavior.



137
138
139
140
141
# File 'lib/claude_agent_sdk/sessions.rb', line 137

def canonicalize_path(dir)
  File.realpath(dir).unicode_normalize(:nfc)
rescue SystemCallError
  File.expand_path(dir).unicode_normalize(:nfc)
end

.config_dirObject

Get the Claude config directory



157
158
159
# File 'lib/claude_agent_sdk/sessions.rb', line 157

def config_dir
  ENV.fetch('CLAUDE_CONFIG_DIR', File.expand_path('~/.claude'))
end

.detect_worktrees(path) ⇒ Object

Probe git for the worktree list with a hard 5-second cap. A stale git lock or hung network mount must not block the listing path forever. Stdlib ‘Timeout.timeout` raises across threads via `Thread#raise`, which corrupts the Async fiber-scheduler state when the caller is inside a reactor, so we drain stdout/stderr on side threads (so a full pipe buffer can’t deadlock git) and SIGKILL the child if the deadline passes. Matches Python’s ‘subprocess.run(…, timeout=5)`.



933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
# File 'lib/claude_agent_sdk/sessions.rb', line 933

def detect_worktrees(path)
  stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
  stdin.close

  # Drain stdout/stderr concurrently — without this, a repo with enough
  # worktrees to overrun the 64 KB pipe buffer causes git to block on
  # write, wait_thr never finishes, and we hit the 5-second watchdog
  # and silently lose every worktree path.
  stdout_buf = +''
  stdout_reader = Thread.new { stdout_buf << stdout.read.to_s }
  stderr_reader = Thread.new { stderr.read }

  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5.0
  until wait_thr.join(0.1)
    next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline

    begin
      Process.kill('KILL', wait_thr.pid)
    rescue Errno::ESRCH
      # Already exited between the join check and the kill.
    end
    wait_thr.join
    stdout_reader.join(0.5)
    stderr_reader.join(0.5)
    return [path]
  end

  stdout_reader.join
  stderr_reader.join

  return [path] unless wait_thr.value.success?

  paths = stdout_buf.lines.filter_map do |line|
    line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
  end
  paths.empty? ? [path] : paths
rescue StandardError
  [path]
ensure
  stdout_reader&.kill if stdout_reader&.alive?
  stderr_reader&.kill if stderr_reader&.alive?
  [stdout, stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
end

.extract_first_prompt_from_head(head) ⇒ Object

Extract the first meaningful user prompt from the head of a JSONL file



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/claude_agent_sdk/sessions.rb', line 230

def extract_first_prompt_from_head(head)
  command_fallback = nil

  head.each_line do |line|
    next unless line.include?('"type":"user"') || line.include?('"type": "user"')
    next if line.include?('"tool_result"')
    next if line.include?('"isMeta":true') || line.include?('"isMeta": true')
    next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')

    entry = JSON.parse(line, symbolize_names: false)
    content = entry.dig('message', 'content')
    next unless content

    texts = if content.is_a?(String)
              [content]
            elsif content.is_a?(Array)
              content.filter_map { |block| block['text'] if block.is_a?(Hash) && block['type'] == 'text' }
            else
              next
            end

    texts.each do |text|
      text = text.gsub(/\n+/, ' ').strip
      next if text.empty?

      if (m = text.match(COMMAND_NAME_RE))
        command_fallback ||= m[1]
        next
      end

      next if text.match?(SKIP_FIRST_PROMPT_PATTERN)

      return text.length > 200 ? "#{text[0, 200]}" : text
    end
  rescue JSON::ParserError
    next
  end

  command_fallback || ''
end

.extract_json_string_field(text, key, last: false) ⇒ Object

Extract a JSON string field value from raw text without full JSON parse



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/claude_agent_sdk/sessions.rb', line 183

def extract_json_string_field(text, key, last: false)
  search_patterns = ["\"#{key}\":\"", "\"#{key}\": \""]
  result = nil

  search_patterns.each do |pattern|
    pos = 0
    loop do
      idx = text.index(pattern, pos)
      break unless idx

      value_start = idx + pattern.length
      value = extract_json_string_value(text, value_start)
      if value
        result = unescape_json_string(value)
        return result unless last
      end
      pos = value_start
    end
  end

  result
end

.extract_json_string_value(text, start) ⇒ Object

Extract string value starting at pos (handles escapes)



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

def extract_json_string_value(text, start)
  pos = start
  while pos < text.length
    ch = text[pos]
    if ch == '\\'
      pos += 2
    elsif ch == '"'
      return text[start...pos]
    else
      pos += 1
    end
  end
  nil
end

.find_project_dir(path) ⇒ Object

Find the project directory for a given path



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/claude_agent_sdk/sessions.rb', line 162

def find_project_dir(path)
  projects_dir = File.join(config_dir, 'projects')
  return nil unless File.directory?(projects_dir)

  sanitized = sanitize_path(path)
  exact_path = File.join(projects_dir, sanitized)
  return exact_path if File.directory?(exact_path)

  # For long paths, scan for prefix match
  if sanitized.length > MAX_SANITIZED_LENGTH
    prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-'
    Dir.children(projects_dir).each do |child|
      candidate = File.join(projects_dir, child)
      return candidate if File.directory?(candidate) && child.start_with?(prefix)
    end
  end

  nil
end

.get_session_info(session_id:, directory: nil) ⇒ SDKSessionInfo?

Read metadata for a single session by ID without a full directory scan.

Parameters:

  • session_id (String)

    UUID of the session to look up

  • directory (String, nil) (defaults to: nil)

    Project directory path. When nil, all project directories are searched.

Returns:

  • (SDKSessionInfo, nil)

    Session info, or nil if not found / sidechain / no summary



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/claude_agent_sdk/sessions.rb', line 402

def get_session_info(session_id:, directory: nil)
  return nil unless session_id.match?(UUID_RE)

  file_name = "#{session_id}.jsonl"
  return get_session_info_for_directory(file_name, directory) if directory

  # No directory — search all project directories.
  projects_dir = File.join(config_dir, 'projects')
  return nil unless File.directory?(projects_dir)

  Dir.children(projects_dir).each do |child|
    entry = File.join(projects_dir, child)
    next unless File.directory?(entry)

    info = read_session_lite(File.join(entry, file_name), nil)
    return info if info
  end
  nil
end

.get_session_info_from_store(session_store:, session_id:, directory: nil) ⇒ Object

Read metadata for a single session from a SessionStore. Store-backed counterpart to get_session_info. Returns nil for an invalid UUID, an unknown session, a sidechain session, or one with no extractable summary.



490
491
492
493
494
495
496
497
498
# File 'lib/claude_agent_sdk/sessions.rb', line 490

def get_session_info_from_store(session_store:, session_id:, directory: nil)
  return nil unless session_id.match?(UUID_RE)

  project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s)
  entries = session_store.load('project_key' => sanitize_path(project_path), 'session_id' => session_id)
  return nil if entries.nil? || entries.empty?

  derive_info_from_entries(session_id, entries, mtime_from_entries(entries), project_path)
end

.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0) ⇒ Array<SessionMessage>

Get messages from a session transcript

Parameters:

  • session_id (String)

    The session UUID

  • directory (String, nil) (defaults to: nil)

    Working directory to search in

  • limit (Integer, nil) (defaults to: nil)

    Maximum number of messages

  • offset (Integer) (defaults to: 0)

    Number of messages to skip

Returns:



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/claude_agent_sdk/sessions.rb', line 428

def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
  return [] unless session_id.match?(UUID_RE)

  offset ||= 0

  file_path = find_session_file(session_id, directory)
  return [] unless file_path && File.exist?(file_path)

  entries = parse_jsonl_entries(file_path)
  chain = build_conversation_chain(entries)
  messages = filter_visible_messages(chain)

  # Apply offset and limit (limit <= 0 yields [], like every other reader)
  messages = messages[offset..] || []
  messages = messages.first([limit, 0].max) if limit
  messages
end

.get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0) ⇒ Object

Read a session’s conversation messages from a SessionStore. Store-backed counterpart to get_session_messages.



502
503
504
505
506
507
508
509
510
# File 'lib/claude_agent_sdk/sessions.rb', line 502

def get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0)
  return [] unless session_id.match?(UUID_RE)

  offset ||= 0
  entries = session_store.load('project_key' => project_key_for_directory(directory), 'session_id' => session_id)
  return [] if entries.nil? || entries.empty?

  entries_to_messages(filter_transcript_entries(entries), limit, offset)
end

.get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil, offset: 0) ⇒ Object

Read a subagent’s conversation messages from a SessionStore. Subagents may live at subagents/agent-<id> or nested under subagents/workflows/<runId>/agent-<id>; scans subkeys to resolve the path when the store implements list_subkeys, else tries the direct path.



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/claude_agent_sdk/sessions.rb', line 543

def get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
  return [] unless session_id.match?(UUID_RE)
  return [] if agent_id.nil? || agent_id.empty?

  project_key = project_key_for_directory(directory)
  subpath = resolve_subagent_subpath(session_store, project_key, session_id, agent_id)
  return [] if subpath.nil?

  entries = session_store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
  return [] if entries.nil? || entries.empty?

  # Drop synthetic agent_metadata entries (they describe the .meta.json
  # sidecar, not transcript lines).
  transcript = entries.reject { |e| e.is_a?(Hash) && e['type'] == 'agent_metadata' }
  return [] if transcript.empty?

  entries_to_subagent_messages(filter_transcript_entries(transcript), limit, offset)
end

.import_session_to_store(session_id:, session_store:, directory: nil, include_subagents: true, batch_size: TranscriptMirrorBatcher::MAX_PENDING_ENTRIES) ⇒ Object

Replay a local on-disk session transcript into a SessionStore (inverse of resume materialization). Streams the JSONL line-by-line and appends in batches. Keys under the on-disk project directory name so the imported session is indistinguishable from a live-mirrored one and resumable via session_store + resume from the original cwd. Adapters should treat entry as an idempotency key so re-import is duplicate-safe.

Raises:

  • (ArgumentError)

    if session_id is not a valid UUID

  • (Errno::ENOENT)

    if the session JSONL cannot be found



571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
# File 'lib/claude_agent_sdk/sessions.rb', line 571

def import_session_to_store(session_id:, session_store:, directory: nil, include_subagents: true,
                            batch_size: TranscriptMirrorBatcher::MAX_PENDING_ENTRIES)
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(UUID_RE)

  resolved = find_session_file(session_id, directory)
  raise Errno::ENOENT, "Session #{session_id} not found" if resolved.nil? || !File.exist?(resolved)

  # Key under the on-disk project directory name — matches
  # file_path_to_session_key / TranscriptMirrorBatcher even when the resolver
  # found the file via worktree fallback or a global scan.
  project_key = File.basename(File.dirname(resolved))
  # &.: an explicit batch_size: nil gets the default too, instead of
  # crashing on nil.positive? (matches the nil-tolerant limit:/offset:
  # convention across this API family).
  batch_size = TranscriptMirrorBatcher::MAX_PENDING_ENTRIES unless batch_size&.positive?

  append_jsonl_file_in_batches(resolved, { 'project_key' => project_key, 'session_id' => session_id },
                               session_store, batch_size)
  return unless include_subagents

  import_subagent_files(resolved, project_key, session_id, session_store, batch_size)
end

.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true) ⇒ Array<SDKSessionInfo>

List sessions for a directory (or all sessions)

Parameters:

  • directory (String, nil) (defaults to: nil)

    Working directory to list sessions for

  • limit (Integer, nil) (defaults to: nil)

    Maximum number of sessions to return

  • offset (Integer) (defaults to: 0)

    Number of sessions to skip (for pagination)

  • include_worktrees (Boolean) (defaults to: true)

    Whether to include git worktree sessions

Returns:

  • (Array<SDKSessionInfo>)

    Sessions sorted by last_modified descending



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

def list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true)
  offset ||= 0
  sessions = if directory
               list_sessions_for_directory(directory, include_worktrees)
             else
               list_all_sessions
             end

  # Sort by last_modified descending, then apply offset and limit.
  # [limit, 0].max: limit <= 0 yields [] across the whole read-API family
  # (a bare first(-1) would raise ArgumentError here but silently clamp on
  # the store paths).
  sessions.sort_by! { |s| -s.last_modified }
  sessions = sessions[offset..] || [] if offset.positive?
  sessions = sessions.first([limit, 0].max) if limit
  sessions
end

.list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0) ⇒ Array<SDKSessionInfo>

List sessions from a SessionStore. Store-backed counterpart to list_sessions. Uses the store’s incremental summaries (one batch call + gap-fill) when available, else falls back to list_sessions + one load per session. Sessions are derived through the same fold the disk path uses, so both paths agree for identical transcript content.

Parameters:

  • session_store (SessionStore)

    store implementing list_session_summaries and/or list_sessions

Returns:



456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/claude_agent_sdk/sessions.rb', line 456

def list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0)
  offset ||= 0
  project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s)
  project_key = sanitize_path(project_path)

  if SessionStore.implements?(session_store, :list_session_summaries)
    via = list_sessions_via_summaries(session_store, project_key, project_path, limit, offset)
    return via unless via.nil?
  end

  unless SessionStore.implements?(session_store, :list_sessions)
    raise ArgumentError,
          'session_store implements neither list_session_summaries nor list_sessions -- cannot list sessions'
  end

  listing = Array(session_store.list_sessions(project_key))
  # Build all-placeholder slots (the shape the summaries fast path uses) and
  # reuse its bounded pagination: sessions are loaded newest-first only
  # until the page fills (~offset + limit + dropped), instead of one full
  # transcript load per listed session before pagination — the sort key
  # (the listing mtime) is known before any load.
  slots = listing.filter_map do |entry|
    sid = entry['session_id']
    next if sid.nil?

    { mtime: entry['mtime'] || 0, session_id: sid, info: nil }
  end
  slots.sort_by! { |slot| -slot[:mtime] }
  paginate_resolving_gaps(session_store, project_key, project_path, slots, limit, offset)
end

.list_subagents_from_store(session_store:, session_id:, directory: nil) ⇒ Object

List subagent IDs for a session from a SessionStore. Requires the store to implement list_subkeys.



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/claude_agent_sdk/sessions.rb', line 514

def list_subagents_from_store(session_store:, session_id:, directory: nil)
  return [] unless session_id.match?(UUID_RE)

  unless SessionStore.implements?(session_store, :list_subkeys)
    raise ArgumentError,
          'session_store does not implement list_subkeys -- cannot list subagents'
  end

  project_key = project_key_for_directory(directory)
  subkeys = Array(session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id))
  seen = {}
  subkeys.filter_map do |subpath|
    next unless subpath.start_with?('subagents/')

    last = subpath.rpartition('/').last
    next unless last.start_with?('agent-')

    agent_id = last.delete_prefix('agent-')
    next if seen[agent_id]

    seen[agent_id] = true
    agent_id
  end
end

.parse_iso_timestamp_ms(timestamp_str) ⇒ Object

Parse an ISO 8601 timestamp string into epoch milliseconds



344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/claude_agent_sdk/sessions.rb', line 344

def parse_iso_timestamp_ms(timestamp_str)
  # Entries are opaque external blobs: a non-String timestamp (e.g. an epoch
  # integer) makes Time.iso8601 raise TypeError, which the ArgumentError
  # rescue would NOT catch and which would escape callers like
  # mtime_from_entries / get_session_info_from_store. Guard the type first.
  return nil unless timestamp_str.is_a?(String)

  require 'time'
  (Time.iso8601(timestamp_str).to_f * 1000).to_i
rescue ArgumentError
  nil
end

.project_key_for_directory(directory = nil) ⇒ String

Derive the SessionStore project_key for a directory (default: cwd).

Uses the same realpath + NFC normalization + djb2-hashed sanitization the CLI uses for project directory names, so keys match between local-disk transcripts and store-mirrored transcripts even on filesystems that decompose Unicode (macOS HFS+).

Parameters:

  • directory (String, Pathname, nil) (defaults to: nil)

    Directory to key (nil = cwd)

Returns:

  • (String)

    The project key



152
153
154
# File 'lib/claude_agent_sdk/sessions.rb', line 152

def project_key_for_directory(directory = nil)
  sanitize_path(canonicalize_path(directory.nil? ? '.' : directory.to_s))
end

.read_session_lite(file_path, project_path) ⇒ Object

Read a single session file with lite (head/tail) strategy



272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/claude_agent_sdk/sessions.rb', line 272

def read_session_lite(file_path, project_path)
  stat = File.stat(file_path)
  return nil if stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate

  head, tail = read_head_tail(file_path, stat.size)

  # Check first line for sidechain
  first_line = head.lines.first || ''
  return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')

  build_session_info(file_path, head, tail, stat, project_path)
rescue StandardError
  nil
end

.read_sessions_from_dir(project_dir, project_path = nil) ⇒ Object

Read all sessions from a project directory



358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/claude_agent_sdk/sessions.rb', line 358

def read_sessions_from_dir(project_dir, project_path = nil)
  return [] unless File.directory?(project_dir)

  sessions = []
  Dir.glob(File.join(project_dir, '*.jsonl')).each do |file_path|
    stem = File.basename(file_path, '.jsonl')
    next unless stem.match?(UUID_RE)

    session = read_session_lite(file_path, project_path)
    sessions << session if session
  end
  sessions
end

.sanitize_path(name) ⇒ Object

Sanitize a filesystem path to a project directory name



125
126
127
128
129
130
# File 'lib/claude_agent_sdk/sessions.rb', line 125

def sanitize_path(name)
  sanitized = name.gsub(SANITIZE_RE, '-')
  return sanitized if sanitized.length <= MAX_SANITIZED_LENGTH

  "#{sanitized[0, MAX_SANITIZED_LENGTH]}-#{simple_hash(name)}"
end

.simple_hash(str) ⇒ Object

Match TypeScript’s simpleHash: signed 32-bit integer, base-36 output. JS’s charCodeAt returns UTF-16 code units, so supplementary characters (emoji, CJK extensions) emit two surrogate code units — iterate over UTF-16LE shorts instead of Unicode codepoints to preserve parity.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/claude_agent_sdk/sessions.rb', line 104

def simple_hash(str)
  h = 0
  str.encode('UTF-16LE').unpack('v*').each do |char_code|
    h = ((h << 5) - h + char_code) & 0xFFFFFFFF
    h -= 0x100000000 if h >= 0x80000000
  end
  h = h.abs

  return '0' if h.zero?

  digits = '0123456789abcdefghijklmnopqrstuvwxyz'
  out = []
  n = h
  while n.positive?
    out.unshift(digits[n % 36])
    n /= 36
  end
  out.join
end

.unescape_json_string(str) ⇒ Object

Unescape a JSON string value



223
224
225
226
227
# File 'lib/claude_agent_sdk/sessions.rb', line 223

def unescape_json_string(str)
  JSON.parse("\"#{str}\"")
rescue JSON::ParserError
  str
end