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
-
.canonicalize_path(dir) ⇒ Object
Resolve a directory to its canonical form (realpath + NFC), matching the CLI’s project-directory naming.
-
.config_dir ⇒ Object
Get the Claude config directory.
-
.detect_worktrees(path) ⇒ Object
Probe git for the worktree list with a hard 5-second cap.
-
.extract_first_prompt_from_head(head) ⇒ Object
Extract the first meaningful user prompt from the head of a JSONL file.
-
.extract_json_string_field(text, key, last: false) ⇒ Object
Extract a JSON string field value from raw text without full JSON parse.
-
.extract_json_string_value(text, start) ⇒ Object
Extract string value starting at pos (handles escapes).
-
.find_project_dir(path) ⇒ Object
Find the project directory for a given path.
-
.get_session_info(session_id:, directory: nil) ⇒ SDKSessionInfo?
Read metadata for a single session by ID without a full directory scan.
-
.get_session_info_from_store(session_store:, session_id:, directory: nil) ⇒ Object
Read metadata for a single session from a SessionStore.
-
.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0) ⇒ Array<SessionMessage>
Get messages from a session transcript.
-
.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.
-
.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.
-
.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).
-
.list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true) ⇒ Array<SDKSessionInfo>
List sessions for a directory (or all sessions).
-
.list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: 0) ⇒ Array<SDKSessionInfo>
List sessions from a SessionStore.
-
.list_subagents_from_store(session_store:, session_id:, directory: nil) ⇒ Object
List subagent IDs for a session from a SessionStore.
-
.parse_iso_timestamp_ms(timestamp_str) ⇒ Object
Parse an ISO 8601 timestamp string into epoch milliseconds.
-
.project_key_for_directory(directory = nil) ⇒ String
Derive the SessionStore
project_keyfor a directory (default: cwd). -
.read_session_lite(file_path, project_path) ⇒ Object
Read a single session file with lite (head/tail) strategy.
-
.read_sessions_from_dir(project_dir, project_path = nil) ⇒ Object
Read all sessions from a project directory.
-
.sanitize_path(name) ⇒ Object
Sanitize a filesystem path to a project directory name.
-
.simple_hash(str) ⇒ Object
Match TypeScript’s simpleHash: signed 32-bit integer, base-36 output.
-
.unescape_json_string(str) ⇒ Object
Unescape a JSON string value.
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.
137 138 139 140 141 |
# File 'lib/claude_agent_sdk/sessions.rb', line 137 def canonicalize_path(dir) File.realpath(dir).unicode_normalize(:nfc) rescue SystemCallError File.(dir).unicode_normalize(:nfc) end |
.config_dir ⇒ Object
Get the Claude config directory
157 158 159 |
# File 'lib/claude_agent_sdk/sessions.rb', line 157 def config_dir ENV.fetch('CLAUDE_CONFIG_DIR', File.('~/.claude')) 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)`.
933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 |
# File 'lib/claude_agent_sdk/sessions.rb', line 933 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
230 231 232 233 234 235 236 237 238 239 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 |
# File 'lib/claude_agent_sdk/sessions.rb', line 230 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
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/claude_agent_sdk/sessions.rb', line 183 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)
207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/claude_agent_sdk/sessions.rb', line 207 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
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/claude_agent_sdk/sessions.rb', line 162 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.
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 |
# File 'lib/claude_agent_sdk/sessions.rb', line 402 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.
490 491 492 493 494 495 496 497 498 |
# File 'lib/claude_agent_sdk/sessions.rb', line 490 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
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 |
# File 'lib/claude_agent_sdk/sessions.rb', line 428 def (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) = (chain) # Apply offset and limit (limit <= 0 yields [], like every other reader) = [offset..] || [] = .first([limit, 0].max) if limit 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.
502 503 504 505 506 507 508 509 510 |
# File 'lib/claude_agent_sdk/sessions.rb', line 502 def (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? (filter_transcript_entries(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.
543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 |
# File 'lib/claude_agent_sdk/sessions.rb', line 543 def (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? (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.
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 |
# File 'lib/claude_agent_sdk/sessions.rb', line 571 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)
378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 |
# File 'lib/claude_agent_sdk/sessions.rb', line 378 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.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 |
# File 'lib/claude_agent_sdk/sessions.rb', line 456 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_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.
514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 |
# File 'lib/claude_agent_sdk/sessions.rb', line 514 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
344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/claude_agent_sdk/sessions.rb', line 344 def () # 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 .is_a?(String) require 'time' (Time.iso8601().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+).
152 153 154 |
# File 'lib/claude_agent_sdk/sessions.rb', line 152 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
272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/claude_agent_sdk/sessions.rb', line 272 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
358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/claude_agent_sdk/sessions.rb', line 358 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
223 224 225 226 227 |
# File 'lib/claude_agent_sdk/sessions.rb', line 223 def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError str end |