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).
-
.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_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. 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)`.
1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 |
# File 'lib/claude_agent_sdk/sessions.rb', line 1005 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
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 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/claude_agent_sdk/sessions.rb', line 240 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
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)
217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'lib/claude_agent_sdk/sessions.rb', line 217 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
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.
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/claude_agent_sdk/sessions.rb', line 415 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.
559 560 561 562 563 564 565 566 567 |
# File 'lib/claude_agent_sdk/sessions.rb', line 559 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
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/claude_agent_sdk/sessions.rb', line 441 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.
571 572 573 574 575 576 577 578 579 |
# File 'lib/claude_agent_sdk/sessions.rb', line 571 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).
495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 |
# File 'lib/claude_agent_sdk/sessions.rb', line 495 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-<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.
612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 |
# File 'lib/claude_agent_sdk/sessions.rb', line 612 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.
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 |
# File 'lib/claude_agent_sdk/sessions.rb', line 640 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)
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/claude_agent_sdk/sessions.rb', line 391 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.
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 |
# File 'lib/claude_agent_sdk/sessions.rb', line 525 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 <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested workflows/<runId>/ paths, in sorted walk order. Mirrors the Python SDK’s list_subagents (#825) — no dedupe (the store variant dedupes because adapter subkey ordering is adapter-defined; the sorted disk walk is already deterministic).
477 478 479 480 481 482 483 484 |
# File 'lib/claude_agent_sdk/sessions.rb', line 477 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.
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 |
# File 'lib/claude_agent_sdk/sessions.rb', line 583 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
357 358 359 360 361 362 363 364 365 366 367 368 |
# File 'lib/claude_agent_sdk/sessions.rb', line 357 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
282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/claude_agent_sdk/sessions.rb', line 282 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
371 372 373 374 375 376 377 378 379 380 381 382 383 |
# File 'lib/claude_agent_sdk/sessions.rb', line 371 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
233 234 235 236 237 |
# File 'lib/claude_agent_sdk/sessions.rb', line 233 def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError str end |