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



1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
# File 'lib/claude_agent_sdk/sessions.rb', line 1005

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



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
270
271
272
273
274
275
276
277
278
279
# File 'lib/claude_agent_sdk/sessions.rb', line 240

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



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)



217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/claude_agent_sdk/sessions.rb', line 217

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



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



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/claude_agent_sdk/sessions.rb', line 415

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.



559
560
561
562
563
564
565
566
567
# File 'lib/claude_agent_sdk/sessions.rb', line 559

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:



441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
# File 'lib/claude_agent_sdk/sessions.rb', line 441

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.



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

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:



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

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



612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/claude_agent_sdk/sessions.rb', line 612

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



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
# File 'lib/claude_agent_sdk/sessions.rb', line 640

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



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/claude_agent_sdk/sessions.rb', line 391

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:



525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
# File 'lib/claude_agent_sdk/sessions.rb', line 525

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 <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested workflows/<runId>/ 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



477
478
479
480
481
482
483
484
# File 'lib/claude_agent_sdk/sessions.rb', line 477

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.



583
584
585
586
587
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 583

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



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

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



282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/claude_agent_sdk/sessions.rb', line 282

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



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

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



233
234
235
236
237
# File 'lib/claude_agent_sdk/sessions.rb', line 233

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