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

.config_dirObject

Get the Claude config directory



94
95
96
# File 'lib/claude_agent_sdk/sessions.rb', line 94

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

.detect_worktrees(path) ⇒ Object



445
446
447
448
449
450
451
452
453
454
455
# File 'lib/claude_agent_sdk/sessions.rb', line 445

def detect_worktrees(path)
  output, _err, status = Open3.capture3('git', '-C', path, 'worktree', 'list', '--porcelain')
  return [path] unless status.success?

  paths = output.lines.filter_map do |line|
    line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
  end
  paths.empty? ? [path] : paths
rescue StandardError
  [path]
end

.extract_first_prompt_from_head(head) ⇒ Object

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



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/claude_agent_sdk/sessions.rb', line 167

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



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/claude_agent_sdk/sessions.rb', line 120

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)



144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/claude_agent_sdk/sessions.rb', line 144

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



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/claude_agent_sdk/sessions.rb', line 99

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



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/claude_agent_sdk/sessions.rb', line 330

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



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

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
  messages = messages[offset..] || []
  messages = messages.first(limit) if limit
  messages
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



309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/claude_agent_sdk/sessions.rb', line 309

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
  sessions.sort_by! { |s| -s.last_modified }
  sessions = sessions[offset..] || [] if offset.positive?
  sessions = sessions.first(limit) if limit
  sessions
end

.read_session_lite(file_path, project_path) ⇒ Object

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



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

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



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/claude_agent_sdk/sessions.rb', line 289

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



86
87
88
89
90
91
# File 'lib/claude_agent_sdk/sessions.rb', line 86

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.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/claude_agent_sdk/sessions.rb', line 65

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



160
161
162
163
164
# File 'lib/claude_agent_sdk/sessions.rb', line 160

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