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 (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.
-
.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. 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.(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.
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.('~/.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).
1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 |
# File 'lib/claude_agent_sdk/sessions.rb', line 1122 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
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/claude_agent_sdk/sessions.rb', line 312 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
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)
276 277 278 279 280 281 282 283 284 285 286 287 288 289 |
# File 'lib/claude_agent_sdk/sessions.rb', line 276 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.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/claude_agent_sdk/sessions.rb', line 226 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).
250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/claude_agent_sdk/sessions.rb', line 250 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
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.
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 |
# File 'lib/claude_agent_sdk/sessions.rb', line 520 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.
664 665 666 667 668 669 670 671 672 |
# File 'lib/claude_agent_sdk/sessions.rb', line 664 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
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/claude_agent_sdk/sessions.rb', line 546 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.
676 677 678 679 680 681 682 683 684 |
# File 'lib/claude_agent_sdk/sessions.rb', line 676 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).
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 |
# File 'lib/claude_agent_sdk/sessions.rb', line 600 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 (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-
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 |
# File 'lib/claude_agent_sdk/sessions.rb', line 717 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.
745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/claude_agent_sdk/sessions.rb', line 745 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)
496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 |
# File 'lib/claude_agent_sdk/sessions.rb', line 496 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.
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 |
# File 'lib/claude_agent_sdk/sessions.rb', line 630 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
582 583 584 585 586 587 588 589 |
# File 'lib/claude_agent_sdk/sessions.rb', line 582 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.
688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 |
# File 'lib/claude_agent_sdk/sessions.rb', line 688 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).
267 268 269 270 271 272 273 |
# File 'lib/claude_agent_sdk/sessions.rb', line 267 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
462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/claude_agent_sdk/sessions.rb', line 462 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+).
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
369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/claude_agent_sdk/sessions.rb', line 369 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
476 477 478 479 480 481 482 483 484 485 486 487 488 |
# File 'lib/claude_agent_sdk/sessions.rb', line 476 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 |
.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.
389 390 391 392 393 394 |
# File 'lib/claude_agent_sdk/sessions.rb', line 389 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.
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
292 293 294 295 296 |
# File 'lib/claude_agent_sdk/sessions.rb', line 292 def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError str end |