claude-agent-sdk 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -88,6 +88,13 @@ module ClaudeAgentSDK
88
88
 
89
89
  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
90
90
 
91
+ # Subagent ids as the CLI writes them (agent-<id>.jsonl): hex ids and
92
+ # prefixed forms like `aprompt_suggestion-1a2b3c`. The store readers
93
+ # synthesize `subagents/agent-<agent_id>` subpaths from caller input, so
94
+ # anything that could re-route a path-, prefix-, or URL-shaped adapter key
95
+ # ('/', '\', '..', '%', NUL, whitespace) is rejected at the boundary.
96
+ AGENT_ID_RE = /\A[A-Za-z0-9._-]+\z/
97
+
91
98
  # Transcript entry types that participate in conversation reads. One shared
92
99
  # constant for the disk (parse_jsonl_entries) and store
93
100
  # (filter_transcript_entries) paths so the two read paths can't drift when
@@ -230,7 +237,8 @@ module ClaudeAgentSDK
230
237
  # which reads only top-level keys — disagreed. A line that doesn't parse
231
238
  # (truncated at the head/tail window edge) keeps the raw-scan value: its
232
239
  # top-level shape can't be checked, and dropping it would regress the
233
- # common case of a true entry cut by the 64KB window.
240
+ # common case of a true entry cut by the 64KB window. Unverified blanks
241
+ # cannot clear a previously verified value: they may be nested tool inputs.
234
242
  def extract_top_level_string_field(text, key, last: false)
235
243
  positions = field_match_positions(text, key)
236
244
  positions.reverse! if last
@@ -247,7 +255,8 @@ module ClaudeAgentSDK
247
255
  next # parseable line without a top-level string value: nested/false match
248
256
  end
249
257
  value = extract_json_string_value(text, value_start)
250
- return unescape_json_string(value) if value
258
+ value = presence(unescape_json_string(value)) if value
259
+ return value if value
251
260
  end
252
261
  nil
253
262
  end
@@ -306,16 +315,60 @@ module ClaudeAgentSDK
306
315
  # Python's `x or None` for the summary/title fallback chains: Ruby's ||
307
316
  # treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
308
317
  # would win over a real first prompt and then fail the summary presence
309
- # check — silently dropping the whole session from disk listings (the
310
- # store path, SessionSummary.presence, already falls through correctly).
311
- # Whitespace-only counts as blank because the final gate strips.
318
+ # check — silently dropping the whole session from disk listings.
319
+ # Whitespace-only counts as blank because the final gate strips. The ONE
320
+ # definition for both summary paths (SessionSummary calls it too): a
321
+ # second copy that only rejected "" let the store path list an invisible
322
+ # whitespace summary the disk path hid.
312
323
  def presence(val)
313
324
  return nil if val.nil?
314
- return nil if val.is_a?(String) && val.strip.empty?
325
+ # An invalidly encoded String (JSON.parse accepts raw invalid UTF-8
326
+ # inside strings) is unusable metadata, and String#strip would raise.
327
+ return nil if val.is_a?(String) && (!val.valid_encoding? || val.strip.empty?)
315
328
 
316
329
  val
317
330
  end
318
331
 
332
+ # Boundary checks for caller-supplied ids. A non-String id gets exactly
333
+ # the malformed-id answer (nil / [] / ArgumentError, per API) instead of
334
+ # a NoMethodError from deep inside (`123.match?`, `123.empty?`) — and so
335
+ # does an invalidly encoded String, on which the regexp match itself
336
+ # raises ArgumentError ("invalid byte sequence").
337
+ def valid_session_id?(session_id)
338
+ session_id.is_a?(String) && session_id.valid_encoding? && session_id.match?(UUID_RE)
339
+ end
340
+
341
+ def valid_agent_id?(agent_id)
342
+ agent_id.is_a?(String) && agent_id.valid_encoding? && agent_id.match?(AGENT_ID_RE) &&
343
+ !%w[. ..].include?(agent_id)
344
+ end
345
+
346
+ # Adapters contractually report mtime as an epoch-ms Numeric (the
347
+ # conformance suite asserts it), but SQL timestamps naturally arrive as
348
+ # ISO-8601 Strings through JSON. Unary minus on a String is String#-@
349
+ # (frozen-string dedup), so String mtimes sorted lexicographically
350
+ # ASCENDING — oldest first, so `limit` cut off the newest sessions and
351
+ # --continue resumed the oldest — and mixed Integer/String lists raised a
352
+ # bare ArgumentError. Coerce defensively wherever an adapter mtime is
353
+ # ordered or compared: numeric strings and ISO-8601 both order
354
+ # correctly; anything else sorts last rather than crashing.
355
+ def sortable_mtime(value)
356
+ case value
357
+ when Numeric then value
358
+ when String then Float(value, exception: false) || parse_iso_timestamp_ms(value) || 0
359
+ else 0
360
+ end
361
+ end
362
+
363
+ # Sort key shared by every session listing, disk and store: newest first,
364
+ # ties broken by session_id ascending. sort_by is not stable, so without
365
+ # the secondary key equal mtimes (coarse adapter clocks, bulk imports)
366
+ # ordered arbitrarily between calls and offset/limit paging could skip or
367
+ # repeat sessions; one key also keeps the two paths in the same order.
368
+ def listing_sort_key(mtime, session_id)
369
+ [-sortable_mtime(mtime), session_id.to_s]
370
+ end
371
+
319
372
  # Extract the first meaningful user prompt from the head of a JSONL file
320
373
  def extract_first_prompt_from_head(head)
321
374
  command_fallback = nil
@@ -380,25 +433,39 @@ module ClaudeAgentSDK
380
433
 
381
434
  head, tail = read_head_tail(file_path, stat.size)
382
435
 
383
- # Check first line for sidechain
384
- return nil if sidechain_first_line?(head.lines.first || '')
436
+ return nil if sidechain_head?(head, stat.size > LITE_READ_BUF_SIZE)
385
437
 
386
438
  build_session_info(file_path, head, tail, stat, project_path)
387
439
  rescue StandardError
388
440
  nil
389
441
  end
390
442
 
391
- # Sidechain classification parses the first line and reads the top-level
392
- # key — the same key the store fold reads (`entry['isSidechain'] == true`).
393
- # The old raw substring scan also matched "isSidechain":true nested inside
394
- # a structured field, hiding the session from disk listings only. A first
395
- # line truncated by the read window can't be shape-checked and keeps the
396
- # substring heuristic.
397
- def sidechain_first_line?(line)
398
- entry = JSON.parse(line)
399
- entry.is_a?(Hash) && entry['isSidechain'] == true
400
- rescue StandardError
401
- line.include?('"isSidechain":true') || line.include?('"isSidechain": true')
443
+ # Sidechain classification reads the top-level key of the FIRST PARSEABLE
444
+ # entry — the entry the store fold classifies from (it sets is_sidechain
445
+ # once, from the first Hash entry; a store never holds an unparseable line
446
+ # since import skips them). The old raw substring scan also matched
447
+ # "isSidechain":true nested inside a structured field, and classifying
448
+ # strictly from line one let a corrupt/blank/non-object first line make
449
+ # the two paths disagree about whether the session exists. So blank,
450
+ # unparseable, and non-object lines are skipped — except a final line cut
451
+ # by the head window: it isn't corrupt, just unseen, so it can't be
452
+ # shape-checked and keeps the substring heuristic.
453
+ def sidechain_head?(head, window_truncated)
454
+ lines = head.lines
455
+ lines.each_with_index do |line, idx|
456
+ # (An invalidly encoded line isn't blank — and strip would raise on it.)
457
+ next if line.valid_encoding? && line.strip.empty?
458
+
459
+ begin
460
+ entry = JSON.parse(line)
461
+ rescue StandardError
462
+ next unless window_truncated && idx == lines.length - 1 && !line.end_with?("\n")
463
+
464
+ return line.include?('"isSidechain":true') || line.include?('"isSidechain": true')
465
+ end
466
+ return entry['isSidechain'] == true if entry.is_a?(Hash)
467
+ end
468
+ false
402
469
  end
403
470
 
404
471
  def read_head_tail(file_path, size)
@@ -417,17 +484,17 @@ module ClaudeAgentSDK
417
484
 
418
485
  def build_session_info(file_path, head, tail, stat, project_path)
419
486
  # User-set title (customTitle) wins over AI-generated title (aiTitle).
420
- # Head fallback covers short sessions where the title entry may not be in tail.
421
- # Each candidate passes through presence so a blank value (e.g. a
422
- # trailing title-clearing entry) falls through instead of short-circuiting.
487
+ # Consult the head only when the tail has no occurrence of that field.
488
+ # Normalize blanks AFTER choosing the latest occurrence: an explicit
489
+ # clearing entry must not resurrect an older title from the head.
423
490
  # Summary-chain fields use the top-level-verified scan: a raw byte scan
424
491
  # also matches these keys nested inside tool_use inputs, reporting tool
425
492
  # arguments as the session title/summary (and diverging from the store
426
493
  # fold, which reads top-level keys only).
427
- custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
428
- presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
429
- presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
430
- presence(extract_top_level_string_field(head, 'aiTitle', last: true))
494
+ custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true) ||
495
+ extract_top_level_string_field(head, 'customTitle', last: true)) ||
496
+ presence(extract_top_level_string_field(tail, 'aiTitle', last: true) ||
497
+ extract_top_level_string_field(head, 'aiTitle', last: true))
431
498
  first_prompt = extract_first_prompt_from_head(head)
432
499
  # lastPrompt tail entry shows what the user was most recently doing.
433
500
  summary = custom_title ||
@@ -439,8 +506,7 @@ module ClaudeAgentSDK
439
506
  # Scope tag extraction to {"type":"tag"} lines — a bare tail scan for
440
507
  # "tag" would match tool_use inputs (git tag, Docker tags, etc.).
441
508
  tag_line = tail.lines.reverse.find { |ln| ln.start_with?('{"type":"tag"') }
442
- tag_value = tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil
443
- tag_value = nil if tag_value && tag_value.empty?
509
+ tag_value = presence(tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil)
444
510
 
445
511
  # created_at from the first ISO timestamp found in the head (epoch ms).
446
512
  # More reliable than stat().birthtime which is unsupported on some
@@ -458,9 +524,11 @@ module ClaudeAgentSDK
458
524
  file_size: stat.size,
459
525
  custom_title: custom_title,
460
526
  first_prompt: first_prompt,
461
- git_branch: extract_json_string_field(tail, 'gitBranch', last: true) ||
462
- extract_json_string_field(head, 'gitBranch', last: false),
463
- cwd: extract_json_string_field(head, 'cwd', last: false) || project_path,
527
+ # presence: blank metadata reads as absent, exactly as the store path
528
+ # (SessionSummary.summary_entry_to_sdk_info) reports it.
529
+ git_branch: presence(extract_json_string_field(tail, 'gitBranch', last: true) ||
530
+ extract_json_string_field(head, 'gitBranch', last: false)),
531
+ cwd: presence(extract_json_string_field(head, 'cwd', last: false)) || project_path,
464
532
  tag: tag_value,
465
533
  created_at: created_at
466
534
  )
@@ -509,11 +577,12 @@ module ClaudeAgentSDK
509
577
  list_all_sessions
510
578
  end
511
579
 
512
- # Sort by last_modified descending, then apply offset and limit.
580
+ # Sort by last_modified descending (ties by session_id, identically to
581
+ # the store path), then apply offset and limit.
513
582
  # [limit, 0].max: limit <= 0 yields [] across the whole read-API family
514
583
  # (a bare first(-1) would raise ArgumentError here but silently clamp on
515
584
  # the store paths).
516
- sessions.sort_by! { |s| -s.last_modified }
585
+ sessions.sort_by! { |s| listing_sort_key(s.last_modified, s.session_id) }
517
586
  sessions = sessions[offset..] || [] if offset.positive?
518
587
  sessions = sessions.first([limit, 0].max) if limit
519
588
  sessions
@@ -526,7 +595,7 @@ module ClaudeAgentSDK
526
595
  # project directories are searched.
527
596
  # @return [SDKSessionInfo, nil] Session info, or nil if not found / sidechain / no summary
528
597
  def get_session_info(session_id:, directory: nil)
529
- return nil unless session_id.match?(UUID_RE)
598
+ return nil unless valid_session_id?(session_id)
530
599
 
531
600
  file_name = "#{session_id}.jsonl"
532
601
  return get_session_info_for_directory(file_name, directory) if directory
@@ -552,7 +621,7 @@ module ClaudeAgentSDK
552
621
  # @param offset [Integer] Number of messages to skip
553
622
  # @return [Array<SessionMessage>] Ordered messages from the session
554
623
  def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
555
- return [] unless session_id.match?(UUID_RE)
624
+ return [] unless valid_session_id?(session_id)
556
625
 
557
626
  offset ||= 0
558
627
 
@@ -588,7 +657,7 @@ module ClaudeAgentSDK
588
657
  # scopes to that project + its worktrees; nil searches all projects)
589
658
  # @return [Array<String>] Subagent IDs
590
659
  def list_subagents(session_id:, directory: nil)
591
- return [] unless session_id.match?(UUID_RE)
660
+ return [] unless valid_session_id?(session_id)
592
661
 
593
662
  subagents_dir = resolve_subagents_dir(session_id, directory)
594
663
  return [] if subagents_dir.nil?
@@ -601,8 +670,7 @@ module ClaudeAgentSDK
601
670
  # reader. This is historical metadata, not a live status query.
602
671
  # @return [Hash{String => Object}, nil] Original CLI fields, or nil if unavailable
603
672
  def get_subagent_metadata(session_id:, agent_id:, directory: nil)
604
- return nil unless session_id.match?(UUID_RE)
605
- return nil if agent_id.nil? || agent_id.empty?
673
+ return nil unless valid_session_id?(session_id) && valid_agent_id?(agent_id)
606
674
 
607
675
  subagents_dir = resolve_subagents_dir(session_id, directory)
608
676
  return nil if subagents_dir.nil?
@@ -625,8 +693,7 @@ module ClaudeAgentSDK
625
693
  # @param offset [Integer] Number of messages to skip
626
694
  # @return [Array<SessionMessage>] Ordered messages from the subagent
627
695
  def get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
628
- return [] unless session_id.match?(UUID_RE)
629
- return [] if agent_id.nil? || agent_id.empty?
696
+ return [] unless valid_session_id?(session_id) && valid_agent_id?(agent_id)
630
697
 
631
698
  subagents_dir = resolve_subagents_dir(session_id, directory)
632
699
  return [] if subagents_dir.nil?
@@ -723,7 +790,7 @@ module ClaudeAgentSDK
723
790
 
724
791
  { mtime: entry['mtime'] || 0, session_id: sid, info: nil }
725
792
  end
726
- slots.sort_by! { |slot| -slot[:mtime] }
793
+ slots.sort_by! { |slot| listing_sort_key(slot[:mtime], slot[:session_id]) }
727
794
  paginate_resolving_gaps(session_store, project_key, project_path, slots, limit, offset)
728
795
  end
729
796
 
@@ -731,7 +798,7 @@ module ClaudeAgentSDK
731
798
  # counterpart to get_session_info. Returns nil for an invalid UUID, an
732
799
  # unknown session, a sidechain session, or one with no extractable summary.
733
800
  def get_session_info_from_store(session_store:, session_id:, directory: nil)
734
- return nil unless session_id.match?(UUID_RE)
801
+ return nil unless valid_session_id?(session_id)
735
802
 
736
803
  project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s)
737
804
  entries = session_store.load('project_key' => sanitize_path(project_path), 'session_id' => session_id)
@@ -743,7 +810,7 @@ module ClaudeAgentSDK
743
810
  # Read a session's conversation messages from a SessionStore. Store-backed
744
811
  # counterpart to get_session_messages.
745
812
  def get_session_messages_from_store(session_store:, session_id:, directory: nil, limit: nil, offset: 0)
746
- return [] unless session_id.match?(UUID_RE)
813
+ return [] unless valid_session_id?(session_id)
747
814
 
748
815
  offset ||= 0
749
816
  entries = session_store.load('project_key' => project_key_for_directory(directory), 'session_id' => session_id)
@@ -755,7 +822,7 @@ module ClaudeAgentSDK
755
822
  # List subagent IDs for a session from a SessionStore. Requires the store to
756
823
  # implement list_subkeys.
757
824
  def list_subagents_from_store(session_store:, session_id:, directory: nil)
758
- return [] unless session_id.match?(UUID_RE)
825
+ return [] unless valid_session_id?(session_id)
759
826
 
760
827
  unless SessionStore.implements?(session_store, :list_subkeys)
761
828
  raise ArgumentError,
@@ -785,8 +852,7 @@ module ClaudeAgentSDK
785
852
  # remain unchanged. Adapter failures propagate, like other store readers.
786
853
  # @return [Hash{String => Object}, nil]
787
854
  def get_subagent_metadata_from_store(session_store:, session_id:, agent_id:, directory: nil)
788
- return nil unless session_id.match?(UUID_RE)
789
- return nil if agent_id.nil? || agent_id.empty?
855
+ return nil unless valid_session_id?(session_id) && valid_agent_id?(agent_id)
790
856
 
791
857
  project_key = project_key_for_directory(directory)
792
858
  subpath = resolve_subagent_subpath(session_store, project_key, session_id, agent_id)
@@ -802,8 +868,7 @@ module ClaudeAgentSDK
802
868
  # subagents/workflows/<runId>/agent-<id>; scans subkeys to resolve the path
803
869
  # when the store implements list_subkeys, else tries the direct path.
804
870
  def get_subagent_messages_from_store(session_store:, session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
805
- return [] unless session_id.match?(UUID_RE)
806
- return [] if agent_id.nil? || agent_id.empty?
871
+ return [] unless valid_session_id?(session_id) && valid_agent_id?(agent_id)
807
872
 
808
873
  project_key = project_key_for_directory(directory)
809
874
  subpath = resolve_subagent_subpath(session_store, project_key, session_id, agent_id)
@@ -834,7 +899,7 @@ module ClaudeAgentSDK
834
899
  # @raise [Errno::ENOENT] if the session JSONL cannot be found
835
900
  def import_session_to_store(session_id:, session_store:, directory: nil, include_subagents: true,
836
901
  batch_size: TranscriptMirrorBatcher::MAX_PENDING_ENTRIES)
837
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(UUID_RE)
902
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless valid_session_id?(session_id)
838
903
 
839
904
  resolved = find_session_file(session_id, directory)
840
905
  raise Errno::ENOENT, "Session #{session_id} not found" if resolved.nil? || !File.exist?(resolved)
@@ -884,12 +949,14 @@ module ClaudeAgentSDK
884
949
  s_mtime = summary['mtime'] || 0
885
950
  if has_list_sessions
886
951
  known = known_mtimes[sid]
887
- # known.nil?: no longer listed (drop). s_mtime < known: stale sidecar (re-fold).
888
- next if known.nil? || s_mtime < known
952
+ # known.nil?: no longer listed (drop). s_mtime < known: stale sidecar
953
+ # (re-fold). Coerced: the sidecar and the listing may report the
954
+ # same clock in different shapes (epoch Integer vs ISO String).
955
+ next if known.nil? || sortable_mtime(s_mtime) < sortable_mtime(known)
889
956
  end
890
957
  fresh[sid] = true
891
958
  info = SessionSummary.summary_entry_to_sdk_info(summary, project_path)
892
- slots << { mtime: s_mtime, info: info } unless info.nil?
959
+ slots << { mtime: s_mtime, session_id: sid, info: info } unless info.nil?
893
960
  end
894
961
  listing.each do |e|
895
962
  next if fresh[e['session_id']]
@@ -897,7 +964,7 @@ module ClaudeAgentSDK
897
964
  slots << { mtime: e['mtime'] || 0, session_id: e['session_id'], info: nil }
898
965
  end
899
966
 
900
- slots.sort_by! { |slot| -slot[:mtime] }
967
+ slots.sort_by! { |slot| listing_sort_key(slot[:mtime], slot[:session_id]) }
901
968
  paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset)
902
969
  end
903
970
 
@@ -970,7 +1037,7 @@ module ClaudeAgentSDK
970
1037
  end
971
1038
 
972
1039
  def apply_sort_limit_offset(results, limit, offset)
973
- results = results.sort_by { |s| -s.last_modified }
1040
+ results = results.sort_by { |s| listing_sort_key(s.last_modified, s.session_id) }
974
1041
  results = results[offset..] || [] if offset.positive?
975
1042
  # A non-nil limit caps the result. limit <= 0 yields [] (matching the disk
976
1043
  # readers' `first(limit) if limit` and entries_to_messages), and the
@@ -1481,7 +1548,8 @@ module ClaudeAgentSDK
1481
1548
  :find_session_file, :stat_candidate, :resolve_subagents_dir,
1482
1549
  :collect_agent_files, :parse_jsonl_entries,
1483
1550
  :build_conversation_chain, :walk_to_leaf, :walk_to_root,
1484
- :filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
1551
+ :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts,
1552
+ :valid_session_id?, :valid_agent_id?, :listing_sort_key, :sidechain_head?,
1485
1553
  :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
1486
1554
  :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
1487
1555
  :filter_transcript_entries, :entries_to_messages,
@@ -55,14 +55,6 @@ module ClaudeAgentSDK
55
55
  # end
56
56
  def self.from_block(session_id: 'default', &block)
57
57
  Enumerator.new do |yielder|
58
- collector = Object.new
59
- def collector.yield(content)
60
- @content = content
61
- end
62
- def collector.content
63
- @content
64
- end
65
-
66
58
  inner_enum = Enumerator.new(&block)
67
59
  inner_enum.each do |content|
68
60
  yielder << user_message(content, session_id: session_id)