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. Known divergence: for a MISSING path Python still resolves symlinks in the existing prefix (so a deleted /tmp/proj on macOS canonicalizes to /private/tmp/proj and its project dir is found); the expand_path fallback resolves none, so deleted-directory lookups under symlinked prefixes can miss.



142
143
144
145
146
# File 'lib/claude_agent_sdk/sessions.rb', line 142

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 (respects CLAUDE_CONFIG_DIR; an empty value is treated as unset, matching the Node CLI and the Python SDK). NFC-normalized on both branches like Python's _get_claude_config_home_dir.



164
165
166
167
168
169
# File 'lib/claude_agent_sdk/sessions.rb', line 164

def config_dir
  dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil)
  return dir.unicode_normalize(:nfc) if dir && !dir.empty?

  File.expand_path('~/.claude').unicode_normalize(:nfc)
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).



1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
# File 'lib/claude_agent_sdk/sessions.rb', line 1122

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



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/claude_agent_sdk/sessions.rb', line 312

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)
    texts = user_entry_texts(entry)
    next unless texts

    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



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/claude_agent_sdk/sessions.rb', line 193

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)



276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/claude_agent_sdk/sessions.rb', line 276

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

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

Byte-scan for "key":" like extract_json_string_field, but verify each match by JSON-parsing its containing line and reading the key at the TOP LEVEL of the entry. The raw scan also matches keys nested inside tool_use inputs (real transcripts carry unescaped "summary":"..." in subagent/teammate tool arguments), which made the disk path report tool-argument text as the session summary/title while the store fold — which reads only top-level keys — disagreed. A line that doesn't parse (truncated at the head/tail window edge) keeps the raw-scan value: its top-level shape can't be checked, and dropping it would regress the common case of a true entry cut by the 64KB window.



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/claude_agent_sdk/sessions.rb', line 226

def extract_top_level_string_field(text, key, last: false)
  positions = field_match_positions(text, key)
  positions.reverse! if last
  parsed_lines = {}
  positions.each do |idx, value_start|
    line_start = (text.rindex("\n", idx) || -1) + 1
    entry = parsed_lines.fetch(line_start) do
      parsed_lines[line_start] = parse_containing_line(text, line_start, idx)
    end
    if entry
      value = entry[key]
      return value if value.is_a?(String)

      next # parseable line without a top-level string value: nested/false match
    end
    value = extract_json_string_value(text, value_start)
    return unescape_json_string(value) if value
  end
  nil
end

.field_match_positions(text, key) ⇒ Object

Match positions of both compact and spaced "key": patterns, sorted by position so first/last selection is by document order (the pattern-major order of extract_json_string_field is wrong when spacings mix).



250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/claude_agent_sdk/sessions.rb', line 250

def field_match_positions(text, key)
  positions = []
  ["\"#{key}\":\"", "\"#{key}\": \""].each do |pattern|
    pos = 0
    while (idx = text.index(pattern, pos))
      value_start = idx + pattern.length
      positions << [idx, value_start]
      pos = value_start
    end
  end
  positions.sort_by!(&:first)
  positions
end

.find_project_dir(path) ⇒ Object

Find the project directory for a given path



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/claude_agent_sdk/sessions.rb', line 172

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



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

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.



664
665
666
667
668
669
670
671
672
# File 'lib/claude_agent_sdk/sessions.rb', line 664

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:



546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
# File 'lib/claude_agent_sdk/sessions.rb', line 546

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)

  begin
    entries = parse_jsonl_entries(file_path)
  rescue SystemCallError
    # TOCTOU between resolution and read (file deleted by another
    # process) — return [] like Python's except OSError, and like the
    # sibling get_subagent_messages.
    return []
  end
  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.



676
677
678
679
680
681
682
683
684
# File 'lib/claude_agent_sdk/sessions.rb', line 676

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(session_id:, agent_id:, directory: nil, limit: nil, offset: 0) ⇒ Array<SessionMessage>

Read a subagent's conversation messages from local disk (counterpart to get_subagent_messages_from_store). First match in sorted walk order wins when the same agent id exists at multiple depths (mirrors Python).

Parameters:

  • session_id (String)

    The session UUID

  • agent_id (String)

    The subagent ID (without the agent- prefix)

  • 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:



600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/claude_agent_sdk/sessions.rb', line 600

def get_subagent_messages(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?

  subagents_dir = resolve_subagents_dir(session_id, directory)
  return [] if subagents_dir.nil?

  _id, path = collect_agent_files(subagents_dir).find { |id, _path| id == agent_id }
  return [] if path.nil?

  begin
    entries = parse_jsonl_entries(path)
  rescue SystemCallError
    # TOCTOU between the walk and the read (mirrors Python's
    # `except OSError: return []`).
    return []
  end
  entries_to_subagent_messages(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- or nested under subagents/workflows//agent-; scans subkeys to resolve the path when the store implements list_subkeys, else tries the direct path.



717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'lib/claude_agent_sdk/sessions.rb', line 717

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



745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'lib/claude_agent_sdk/sessions.rb', line 745

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



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
# File 'lib/claude_agent_sdk/sessions.rb', line 496

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:



630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/claude_agent_sdk/sessions.rb', line 630

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(session_id:, directory: nil) ⇒ Array<String>

List subagent IDs recorded for a session on local disk (counterpart to list_subagents_from_store). Scans //subagents/**/agent-.jsonl, including nested workflows// paths, in sorted walk order. Mirrors the Python SDK's list_subagents (#825) — no dedupe (the store variant dedupes because adapter subkey ordering is adapter-defined; the sorted disk walk is already deterministic).

Parameters:

  • session_id (String)

    The session UUID

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

    Working directory to search in (strictly scopes to that project + its worktrees; nil searches all projects)

Returns:

  • (Array<String>)

    Subagent IDs



582
583
584
585
586
587
588
589
# File 'lib/claude_agent_sdk/sessions.rb', line 582

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

  subagents_dir = resolve_subagents_dir(session_id, directory)
  return [] if subagents_dir.nil?

  collect_agent_files(subagents_dir).map(&:first)
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.



688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
# File 'lib/claude_agent_sdk/sessions.rb', line 688

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_containing_line(text, line_start, idx) ⇒ Object

Parse the JSONL line containing byte offset idx. Returns the entry Hash, {} for parseable non-Hash lines (a match inside one is nested by definition), or nil when the line doesn't parse (window truncation).



267
268
269
270
271
272
273
# File 'lib/claude_agent_sdk/sessions.rb', line 267

def parse_containing_line(text, line_start, idx)
  line_end = text.index("\n", idx) || text.length
  entry = JSON.parse(text[line_start...line_end])
  entry.is_a?(Hash) ? entry : {}
rescue StandardError
  nil
end

.parse_iso_timestamp_ms(timestamp_str) ⇒ Object

Parse an ISO 8601 timestamp string into epoch milliseconds



462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/claude_agent_sdk/sessions.rb', line 462

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



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

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



369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/claude_agent_sdk/sessions.rb', line 369

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
  return nil if sidechain_first_line?(head.lines.first || '')

  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



476
477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/claude_agent_sdk/sessions.rb', line 476

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

.sidechain_first_line?(line) ⇒ Boolean

Sidechain classification parses the first line and reads the top-level key — the same key the store fold reads (entry['isSidechain'] == true). The old raw substring scan also matched "isSidechain":true nested inside a structured field, hiding the session from disk listings only. A first line truncated by the read window can't be shape-checked and keeps the substring heuristic.

Returns:

  • (Boolean)


389
390
391
392
393
394
# File 'lib/claude_agent_sdk/sessions.rb', line 389

def sidechain_first_line?(line)
  entry = JSON.parse(line)
  entry.is_a?(Hash) && entry['isSidechain'] == true
rescue StandardError
  line.include?('"isSidechain":true') || line.include?('"isSidechain": true')
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



292
293
294
295
296
# File 'lib/claude_agent_sdk/sessions.rb', line 292

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