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



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

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

.detect_worktrees(path) ⇒ Object



441
442
443
444
445
446
447
448
449
450
451
# File 'lib/claude_agent_sdk/sessions.rb', line 441

def detect_worktrees(path)
  output = `git -C #{Shellwords.escape(path)} worktree list --porcelain 2>/dev/null`
  return [path] unless $CHILD_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



166
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
# File 'lib/claude_agent_sdk/sessions.rb', line 166

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



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

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)



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

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



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

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



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

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:



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

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

  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



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

def list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true)
  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



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

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



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

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



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

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



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

def simple_hash(str)
  h = 0
  str.each_char do |ch|
    char_code = ch.ord
    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



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

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