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
-
.agent_metadata_sidecar_path(transcript_path) ⇒ String
agent-
.jsonl -> agent- .meta.json in the same directory. -
.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 (respects CLAUDE_CONFIG_DIR; an empty value is treated as unset, matching the Node CLI and the Python SDK).
-
.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).
-
.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. -
.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). -
.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(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).
-
.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(session_id:, directory: nil) ⇒ Array<String>
List subagent IDs recorded for a session on local disk (counterpart to list_subagents_from_store).
-
.list_subagents_from_store(session_store:, session_id:, directory: nil) ⇒ Object
List subagent IDs for a session from a SessionStore.
-
.parse_containing_line(text, line_start, idx) ⇒ Object
Parse the JSONL line containing byte offset
idx. -
.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.
-
.sidechain_first_line?(line) ⇒ Boolean
Sidechain classification parses the first line and reads the top-level key — the same key the store fold reads (
entry['isSidechain'] == true). -
.simple_hash(str) ⇒ Object
Match TypeScript's simpleHash: signed 32-bit integer, base-36 output.
-
.split_agent_metadata(entries) ⇒ Array(Hash, Array)
Separate the synthetic agent_metadata entry from transcript lines.
-
.unescape_json_string(str) ⇒ Object
Unescape a JSON string value.
Class Method Details
.agent_metadata_sidecar_path(transcript_path) ⇒ String
agent-
645 646 647 |
# File 'lib/claude_agent_sdk/sessions.rb', line 645 def (transcript_path) "#{transcript_path.delete_suffix('.jsonl')}.meta.json" end |
.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.
150 151 152 153 154 |
# File 'lib/claude_agent_sdk/sessions.rb', line 150 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 (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.
172 173 174 175 176 177 |
# File 'lib/claude_agent_sdk/sessions.rb', line 172 def config_dir dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil) return dir.unicode_normalize(:nfc) if dir && !dir.empty? File.('~/.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).
1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 |
# File 'lib/claude_agent_sdk/sessions.rb', line 1214 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
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/claude_agent_sdk/sessions.rb', line 320 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
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/claude_agent_sdk/sessions.rb', line 201 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)
284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/claude_agent_sdk/sessions.rb', line 284 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.
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/claude_agent_sdk/sessions.rb', line 234 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).
258 259 260 261 262 263 264 265 266 267 268 269 270 |
# File 'lib/claude_agent_sdk/sessions.rb', line 258 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
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/claude_agent_sdk/sessions.rb', line 180 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.
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 |
# File 'lib/claude_agent_sdk/sessions.rb', line 528 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.
714 715 716 717 718 719 720 721 722 |
# File 'lib/claude_agent_sdk/sessions.rb', line 714 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
554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 |
# File 'lib/claude_agent_sdk/sessions.rb', line 554 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) 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) = (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.
726 727 728 729 730 731 732 733 734 |
# File 'lib/claude_agent_sdk/sessions.rb', line 726 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(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).
608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 |
# File 'lib/claude_agent_sdk/sessions.rb', line 608 def (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 # The .meta.json sidecar next to the transcript records which Agent # tool_use spawned this subagent (and, for nested subagents, the parent # agent id). Like the transcript read above this is best-effort: any # failure to read it degrades to "no metadata" rather than raising. = begin (path) rescue SystemCallError nil end parent_tool_use_id, parent_agent_id = () (entries, limit, offset, parent_tool_use_id, parent_agent_id) 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-
767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 |
# File 'lib/claude_agent_sdk/sessions.rb', line 767 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? # The synthetic agent_metadata entry (the store's copy of the .meta.json # sidecar) records which Agent tool_use spawned this subagent. Recover the # parent ids from it, then drop it: it is not a transcript line. , transcript = (entries) return [] if transcript.empty? parent_tool_use_id, parent_agent_id = () (filter_transcript_entries(transcript), limit, offset, parent_tool_use_id, parent_agent_id) 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.
798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 |
# File 'lib/claude_agent_sdk/sessions.rb', line 798 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)
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 |
# File 'lib/claude_agent_sdk/sessions.rb', line 504 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.
680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 |
# File 'lib/claude_agent_sdk/sessions.rb', line 680 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
590 591 592 593 594 595 596 597 |
# File 'lib/claude_agent_sdk/sessions.rb', line 590 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.
738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 |
# File 'lib/claude_agent_sdk/sessions.rb', line 738 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).
275 276 277 278 279 280 281 |
# File 'lib/claude_agent_sdk/sessions.rb', line 275 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
470 471 472 473 474 475 476 477 478 479 480 481 |
# File 'lib/claude_agent_sdk/sessions.rb', line 470 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+).
165 166 167 |
# File 'lib/claude_agent_sdk/sessions.rb', line 165 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
377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/claude_agent_sdk/sessions.rb', line 377 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 return nil if sidechain_first_line?(head.lines.first || '') 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
484 485 486 487 488 489 490 491 492 493 494 495 496 |
# File 'lib/claude_agent_sdk/sessions.rb', line 484 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
133 134 135 136 137 138 |
# File 'lib/claude_agent_sdk/sessions.rb', line 133 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 |
.sidechain_first_line?(line) ⇒ Boolean
Sidechain classification parses the first line and reads the top-level
key — the same key the store fold reads (entry['isSidechain'] == true).
The old raw substring scan also matched "isSidechain":true nested inside
a structured field, hiding the session from disk listings only. A first
line truncated by the read window can't be shape-checked and keeps the
substring heuristic.
397 398 399 400 401 402 |
# File 'lib/claude_agent_sdk/sessions.rb', line 397 def sidechain_first_line?(line) entry = JSON.parse(line) entry.is_a?(Hash) && entry['isSidechain'] == true rescue StandardError line.include?('"isSidechain":true') || line.include?('"isSidechain": true') 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.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/claude_agent_sdk/sessions.rb', line 112 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 |
.split_agent_metadata(entries) ⇒ Array(Hash, Array)
Separate the synthetic agent_metadata entry from transcript lines.
A subagent's SessionStore stream carries its .meta.json sidecar as { 'type' => 'agent_metadata', ... } entries alongside the transcript. Returns [metadata, transcript] where metadata is the LAST such entry (it is rewritten on resume, so last wins) or nil.
657 658 659 660 661 662 663 664 665 666 667 668 |
# File 'lib/claude_agent_sdk/sessions.rb', line 657 def (entries) = nil transcript = [] entries.each do |e| if e.is_a?(Hash) && e['type'] == 'agent_metadata' = e else transcript << e end end [, transcript] end |
.unescape_json_string(str) ⇒ Object
Unescape a JSON string value
300 301 302 303 304 |
# File 'lib/claude_agent_sdk/sessions.rb', line 300 def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError str end |