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



1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
# File 'lib/claude_agent_sdk/sessions.rb', line 1098

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



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'lib/claude_agent_sdk/sessions.rb', line 508

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.



652
653
654
655
656
657
658
659
660
# File 'lib/claude_agent_sdk/sessions.rb', line 652

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:



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# File 'lib/claude_agent_sdk/sessions.rb', line 534

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.



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

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:



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
# File 'lib/claude_agent_sdk/sessions.rb', line 588

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.



705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
# File 'lib/claude_agent_sdk/sessions.rb', line 705

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



733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
# File 'lib/claude_agent_sdk/sessions.rb', line 733

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



484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/claude_agent_sdk/sessions.rb', line 484

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:



618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/claude_agent_sdk/sessions.rb', line 618

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



570
571
572
573
574
575
576
577
# File 'lib/claude_agent_sdk/sessions.rb', line 570

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.



676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
# File 'lib/claude_agent_sdk/sessions.rb', line 676

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



450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/claude_agent_sdk/sessions.rb', line 450

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
382
# 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
  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



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

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



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