claude-agent-sdk 0.17.0 → 0.18.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.
@@ -134,6 +134,11 @@ module ClaudeAgentSDK
134
134
  # realpath can't resolve it (e.g. the directory does not exist yet) — Ruby's
135
135
  # File.realpath raises on missing paths whereas Python's os.path.realpath is
136
136
  # lexical for the missing suffix, so expand_path restores that behavior.
137
+ # Known divergence: for a MISSING path Python still resolves symlinks in
138
+ # the existing prefix (so a deleted /tmp/proj on macOS canonicalizes to
139
+ # /private/tmp/proj and its project dir is found); the expand_path
140
+ # fallback resolves none, so deleted-directory lookups under symlinked
141
+ # prefixes can miss.
137
142
  def canonicalize_path(dir)
138
143
  File.realpath(dir).unicode_normalize(:nfc)
139
144
  rescue SystemCallError
@@ -153,9 +158,14 @@ module ClaudeAgentSDK
153
158
  sanitize_path(canonicalize_path(directory.nil? ? '.' : directory.to_s))
154
159
  end
155
160
 
156
- # Get the Claude config directory
161
+ # Get the Claude config directory (respects CLAUDE_CONFIG_DIR; an empty
162
+ # value is treated as unset, matching the Node CLI and the Python SDK).
163
+ # NFC-normalized on both branches like Python's _get_claude_config_home_dir.
157
164
  def config_dir
158
- ENV.fetch('CLAUDE_CONFIG_DIR', File.expand_path('~/.claude'))
165
+ dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil)
166
+ return dir.unicode_normalize(:nfc) if dir && !dir.empty?
167
+
168
+ File.expand_path('~/.claude').unicode_normalize(:nfc)
159
169
  end
160
170
 
161
171
  # Find the project directory for a given path
@@ -319,10 +329,13 @@ module ClaudeAgentSDK
319
329
  tag_value = tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil
320
330
  tag_value = nil if tag_value && tag_value.empty?
321
331
 
322
- # created_at from first entry's ISO timestamp (epoch ms). More reliable
323
- # than stat().birthtime which is unsupported on some filesystems.
324
- first_line = head.lines.first || ''
325
- first_timestamp = extract_json_string_field(first_line, 'timestamp', last: false)
332
+ # created_at from the first ISO timestamp found in the head (epoch ms).
333
+ # More reliable than stat().birthtime which is unsupported on some
334
+ # filesystems. Scans the whole head rather than only the first line
335
+ # because the first record may be a metadata-only entry (e.g.
336
+ # permission-mode) with no timestamp field; the first user/assistant
337
+ # record that follows does carry one (Python #907).
338
+ first_timestamp = extract_json_string_field(head, 'timestamp', last: false)
326
339
  created_at = parse_iso_timestamp_ms(first_timestamp) if first_timestamp
327
340
 
328
341
  SDKSessionInfo.new(
@@ -433,7 +446,14 @@ module ClaudeAgentSDK
433
446
  file_path = find_session_file(session_id, directory)
434
447
  return [] unless file_path && File.exist?(file_path)
435
448
 
436
- entries = parse_jsonl_entries(file_path)
449
+ begin
450
+ entries = parse_jsonl_entries(file_path)
451
+ rescue SystemCallError
452
+ # TOCTOU between resolution and read (file deleted by another
453
+ # process) — return [] like Python's except OSError, and like the
454
+ # sibling get_subagent_messages.
455
+ return []
456
+ end
437
457
  chain = build_conversation_chain(entries)
438
458
  messages = filter_visible_messages(chain)
439
459
 
@@ -443,6 +463,55 @@ module ClaudeAgentSDK
443
463
  messages
444
464
  end
445
465
 
466
+ # List subagent IDs recorded for a session on local disk (counterpart to
467
+ # list_subagents_from_store). Scans
468
+ # <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested
469
+ # workflows/<runId>/ paths, in sorted walk order. Mirrors the Python SDK's
470
+ # list_subagents (#825) — no dedupe (the store variant dedupes because
471
+ # adapter subkey ordering is adapter-defined; the sorted disk walk is
472
+ # already deterministic).
473
+ # @param session_id [String] The session UUID
474
+ # @param directory [String, nil] Working directory to search in (strictly
475
+ # scopes to that project + its worktrees; nil searches all projects)
476
+ # @return [Array<String>] Subagent IDs
477
+ def list_subagents(session_id:, directory: nil)
478
+ return [] unless session_id.match?(UUID_RE)
479
+
480
+ subagents_dir = resolve_subagents_dir(session_id, directory)
481
+ return [] if subagents_dir.nil?
482
+
483
+ collect_agent_files(subagents_dir).map(&:first)
484
+ end
485
+
486
+ # Read a subagent's conversation messages from local disk (counterpart to
487
+ # get_subagent_messages_from_store). First match in sorted walk order wins
488
+ # when the same agent id exists at multiple depths (mirrors Python).
489
+ # @param session_id [String] The session UUID
490
+ # @param agent_id [String] The subagent ID (without the agent- prefix)
491
+ # @param directory [String, nil] Working directory to search in
492
+ # @param limit [Integer, nil] Maximum number of messages
493
+ # @param offset [Integer] Number of messages to skip
494
+ # @return [Array<SessionMessage>] Ordered messages from the subagent
495
+ def get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
496
+ return [] unless session_id.match?(UUID_RE)
497
+ return [] if agent_id.nil? || agent_id.empty?
498
+
499
+ subagents_dir = resolve_subagents_dir(session_id, directory)
500
+ return [] if subagents_dir.nil?
501
+
502
+ _id, path = collect_agent_files(subagents_dir).find { |id, _path| id == agent_id }
503
+ return [] if path.nil?
504
+
505
+ begin
506
+ entries = parse_jsonl_entries(path)
507
+ rescue SystemCallError
508
+ # TOCTOU between the walk and the read (mirrors Python's
509
+ # `except OSError: return []`).
510
+ return []
511
+ end
512
+ entries_to_subagent_messages(entries, limit, offset)
513
+ end
514
+
446
515
  # ---- SessionStore-backed reads (store counterparts to the disk readers) ----
447
516
 
448
517
  # List sessions from a SessionStore. Store-backed counterpart to
@@ -854,7 +923,10 @@ module ClaudeAgentSDK
854
923
  end
855
924
 
856
925
  def get_session_info_for_directory(file_name, directory)
857
- canonical = File.realpath(directory).unicode_normalize(:nfc)
926
+ # canonicalize_path (not raw realpath): a nonexistent directory must
927
+ # canonicalize lexically and yield nil from find_project_dir — Python's
928
+ # os.path.realpath never raises here.
929
+ canonical = canonicalize_path(directory)
858
930
  project_dir = find_project_dir(canonical)
859
931
  if project_dir
860
932
  info = read_session_lite(File.join(project_dir, file_name), canonical)
@@ -876,7 +948,7 @@ module ClaudeAgentSDK
876
948
  end
877
949
 
878
950
  def list_sessions_for_directory(directory, include_worktrees)
879
- path = File.realpath(directory).unicode_normalize(:nfc)
951
+ path = canonicalize_path(directory)
880
952
 
881
953
  worktree_paths = []
882
954
  worktree_paths = detect_worktrees(path) if include_worktrees
@@ -978,36 +1050,83 @@ module ClaudeAgentSDK
978
1050
  projects_dir = File.join(config_dir, 'projects')
979
1051
  return nil unless File.directory?(projects_dir)
980
1052
 
1053
+ file_name = "#{session_id}.jsonl"
1054
+
981
1055
  if directory
982
- path = File.realpath(directory).unicode_normalize(:nfc)
983
- project_dir = find_project_dir(path)
984
- if project_dir
985
- candidate = File.join(project_dir, "#{session_id}.jsonl")
986
- return candidate if File.exist?(candidate)
987
- end
1056
+ path = canonicalize_path(directory)
1057
+ found = stat_candidate(find_project_dir(path), file_name)
1058
+ return found if found
988
1059
 
989
- # Try worktrees
990
1060
  detect_worktrees(path).each do |wt_path|
991
- pd = find_project_dir(wt_path)
992
- next unless pd
1061
+ next if wt_path == path # already tried above
993
1062
 
994
- candidate = File.join(pd, "#{session_id}.jsonl")
995
- return candidate if File.exist?(candidate)
1063
+ found = stat_candidate(find_project_dir(wt_path), file_name)
1064
+ return found if found
996
1065
  end
1066
+
1067
+ # An explicit directory strictly scopes the search — never fall
1068
+ # through to the global scan, which could resolve an unrelated
1069
+ # project's same-id session (mirrors Python's _resolve_session_file_path).
1070
+ return nil
997
1071
  end
998
1072
 
999
- # Scan all project dirs
1073
+ # No directory provided — search all project directories.
1000
1074
  Dir.children(projects_dir).each do |child|
1001
1075
  dir = File.join(projects_dir, child)
1002
1076
  next unless File.directory?(dir)
1003
1077
 
1004
- candidate = File.join(dir, "#{session_id}.jsonl")
1005
- return candidate if File.exist?(candidate)
1078
+ found = stat_candidate(dir, file_name)
1079
+ return found if found
1006
1080
  end
1007
1081
 
1008
1082
  nil
1009
1083
  end
1010
1084
 
1085
+ # Mirrors Python's _stat_candidate: a candidate counts only when it
1086
+ # exists AND is non-empty — a 0-byte stub in one project dir must not
1087
+ # stop the search when the real transcript lives under another
1088
+ # worktree's project dir (same hazard SessionMutations.try_append guards).
1089
+ def stat_candidate(project_dir, file_name)
1090
+ return nil if project_dir.nil?
1091
+
1092
+ candidate = File.join(project_dir, file_name)
1093
+ File.size(candidate).positive? ? candidate : nil
1094
+ rescue SystemCallError
1095
+ nil
1096
+ end
1097
+
1098
+ # Resolve the on-disk subagents directory for a session:
1099
+ # <projectDir>/<sessionId>/subagents (mirrors Python's
1100
+ # _resolve_subagents_dir). nil when the session transcript can't be found.
1101
+ def resolve_subagents_dir(session_id, directory)
1102
+ resolved = find_session_file(session_id, directory)
1103
+ return nil if resolved.nil?
1104
+
1105
+ File.join(resolved.delete_suffix('.jsonl'), 'subagents')
1106
+ end
1107
+
1108
+ # Depth-first sorted walk collecting [agent_id, path] pairs for
1109
+ # agent-<id>.jsonl files, recursing into subdirectories (e.g.
1110
+ # workflows/<runId>/) in the same sorted interleave. Mirrors Python's
1111
+ # _collect_agent_files: no dedupe; [] for a missing/unreadable base dir.
1112
+ def collect_agent_files(base_dir, results = [])
1113
+ begin
1114
+ children = Dir.children(base_dir).sort
1115
+ rescue SystemCallError
1116
+ return results
1117
+ end
1118
+
1119
+ children.each do |name|
1120
+ path = File.join(base_dir, name)
1121
+ if File.directory?(path)
1122
+ collect_agent_files(path, results)
1123
+ elsif File.file?(path) && name.start_with?('agent-') && name.end_with?('.jsonl')
1124
+ results << [name.delete_prefix('agent-').delete_suffix('.jsonl'), path]
1125
+ end
1126
+ end
1127
+ results
1128
+ end
1129
+
1011
1130
  def parse_jsonl_entries(file_path)
1012
1131
  entries = []
1013
1132
 
@@ -1113,7 +1232,8 @@ module ClaudeAgentSDK
1113
1232
  private_class_method :get_session_info_for_directory,
1114
1233
  :list_sessions_for_directory, :list_all_sessions,
1115
1234
  :deduplicate_sessions,
1116
- :find_session_file, :parse_jsonl_entries,
1235
+ :find_session_file, :stat_candidate, :resolve_subagents_dir,
1236
+ :collect_agent_files, :parse_jsonl_entries,
1117
1237
  :build_conversation_chain, :walk_to_leaf, :walk_to_root,
1118
1238
  :filter_visible_messages, :read_head_tail, :build_session_info,
1119
1239
  :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
@@ -14,6 +14,8 @@ module ClaudeAgentSDK
14
14
  class SubprocessCLITransport < Transport
15
15
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
16
16
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
17
+ SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
18
+ VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
17
19
  RECENT_STDERR_LINES_LIMIT = 20
18
20
 
19
21
  # Track live CLI subprocesses so we can terminate them when the parent Ruby
@@ -143,6 +145,37 @@ module ClaudeAgentSDK
143
145
  )
144
146
  end
145
147
 
148
+ # Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
149
+ # subprocess env when an OTel span is active. Guard via defined? +
150
+ # respond_to?, not require: an active span implies the constant is loaded,
151
+ # and requiring here would break against the test mock / optional gem
152
+ # group. Gate on the carrier's traceparent key (the W3C propagator writes
153
+ # it only for a valid span context) so a baggage-only carrier or a noop
154
+ # propagator preserves inherited env.
155
+ def inject_otel_trace_context(process_env, custom_env)
156
+ return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
157
+
158
+ carrier = {}
159
+ OpenTelemetry.propagation.inject(carrier)
160
+ return unless carrier.key?('traceparent')
161
+
162
+ # Active span: scrub stale inherited W3C context (CI/k8s ambient env)
163
+ # before writing fresh values, so an inherited TRACESTATE is never
164
+ # paired with a new TRACEPARENT. nil actively unsets (spawn overlay
165
+ # semantics — see the CLAUDECODE note in #connect; Python pops from a
166
+ # complete env dict instead). Explicit options.env keys always win.
167
+ %w[TRACEPARENT TRACESTATE].each do |key|
168
+ process_env[key] = nil unless custom_env.key?(key)
169
+ end
170
+ carrier.each do |key, value|
171
+ env_key = key.upcase
172
+ process_env[env_key] = value unless custom_env.key?(env_key)
173
+ end
174
+ rescue StandardError, ScriptError
175
+ # Best-effort tracing must never break connect() (Python: except
176
+ # Exception). ScriptError too: NotImplementedError < ScriptError.
177
+ end
178
+
146
179
  def build_command
147
180
  CommandBuilder.new(@cli_path, @options).build
148
181
  end
@@ -161,8 +194,18 @@ module ClaudeAgentSDK
161
194
  # launches Claude Code from within an existing Claude Code terminal.
162
195
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
163
196
  # the env hash on top of the parent environment; a nil value actively unsets.
164
- process_env = ENV.to_h.merge('CLAUDECODE' => nil, 'CLAUDE_AGENT_SDK_VERSION' => VERSION).merge(custom_env)
165
- process_env['CLAUDE_CODE_ENTRYPOINT'] ||= 'sdk-rb'
197
+ # ENTRYPOINT defaults to sdk-rb regardless of inherited process env
198
+ # (the old ||= let an inherited 'cli' win and mis-attribute telemetry);
199
+ # options.env may still override it. VERSION is merged last: always
200
+ # set by the SDK, never overridable (Python merge-order parity).
201
+ process_env = ENV.to_h
202
+ .merge('CLAUDECODE' => nil, 'CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb')
203
+ .merge(custom_env)
204
+ .merge('CLAUDE_AGENT_SDK_VERSION' => VERSION)
205
+ # Propagate the active OTel trace context to the CLI so its spans parent
206
+ # under the caller's distributed trace (Python SDK #821 parity). No-op
207
+ # when opentelemetry is not loaded or there is no active span.
208
+ inject_otel_trace_context(process_env, custom_env)
166
209
  process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
167
210
  process_env['PWD'] = @cwd.to_s if @cwd
168
211
 
@@ -171,9 +214,22 @@ module ClaudeAgentSDK
171
214
 
172
215
  begin
173
216
  # Start process using Open3
174
- opts = { chdir: @cwd&.to_s }.compact
217
+ # :uid mirrors Python's anyio.open_process(user=...): String username
218
+ # or Integer uid (Unix; requires privileges — typically root). The
219
+ # .compact is mandatory: uid: nil raises TypeError on every connect.
220
+ # On Windows spawn raises for :uid, wrapped below into
221
+ # CLIConnectionError — fail-loud instead of the old silent ignore.
222
+ opts = { chdir: @cwd&.to_s, uid: @options.user }.compact
175
223
 
176
224
  @stdin, @stdout, @stderr, @process = Open3.popen3(process_env, *cmd, opts)
225
+ # The CLI emits UTF-8 regardless of the parent locale. popen3 pipes
226
+ # default to Encoding.default_external (US-ASCII under LANG=C/LC_ALL=C
227
+ # — minimal Docker images, systemd, CI), which makes String#strip on
228
+ # multibyte CLI output raise Encoding::CompatibilityError and kill the
229
+ # read loop (its rescue only catches IOError). Mirrors the Python
230
+ # SDK's TextReceiveStream(stdout), which always decodes UTF-8.
231
+ @stdout&.set_encoding(Encoding::UTF_8)
232
+ @stderr&.set_encoding(Encoding::UTF_8)
177
233
  self.class.register_active_process(@process)
178
234
 
179
235
  # Always drain stderr to prevent pipe buffer deadlock.
@@ -209,7 +265,10 @@ module ClaudeAgentSDK
209
265
  error = CLINotFoundError.new("Claude Code not found at: #{@cli_path}")
210
266
  @exit_error = error
211
267
  raise error
212
- rescue StandardError => e
268
+ rescue StandardError, NotImplementedError => e
269
+ # NotImplementedError < ScriptError, not StandardError (the trap this
270
+ # repo keeps hitting): spawn raises it for :uid on platforms without
271
+ # setuid (Windows), and it must wrap like every other spawn failure.
213
272
  error = CLIConnectionError.new("Failed to start Claude Code: #{e}")
214
273
  @exit_error = error
215
274
  raise error
@@ -219,7 +278,7 @@ module ClaudeAgentSDK
219
278
  def handle_stderr
220
279
  return unless @stderr
221
280
 
222
- @stderr.each_line do |line|
281
+ @stderr.each_line("\n", @max_buffer_size + 1) do |line|
223
282
  line_str = line.chomp
224
283
  next if line_str.empty?
225
284
 
@@ -257,7 +316,7 @@ module ClaudeAgentSDK
257
316
  def drain_stderr_with_accumulation
258
317
  return unless @stderr
259
318
 
260
- @stderr.each_line do |line|
319
+ @stderr.each_line("\n", @max_buffer_size + 1) do |line|
261
320
  line_str = line.chomp
262
321
  next if line_str.empty?
263
322
 
@@ -400,14 +459,21 @@ module ClaudeAgentSDK
400
459
  end
401
460
 
402
461
  def end_input
403
- return unless @stdin
462
+ # Under @stdin_mutex like write/close (the transport's documented
463
+ # locking protocol; Python's end_input takes _write_lock too). The
464
+ # nil-guard must live INSIDE the critical section or the TOCTOU
465
+ # returns. NOTE: non-reentrant — close() inlines its own stdin
466
+ # handling and must never delegate here.
467
+ @stdin_mutex.synchronize do
468
+ return unless @stdin
404
469
 
405
- begin
406
- @stdin.close
407
- rescue StandardError
408
- # Ignore
470
+ begin
471
+ @stdin.close
472
+ rescue StandardError
473
+ # Ignore
474
+ end
475
+ @stdin = nil
409
476
  end
410
- @stdin = nil
411
477
  end
412
478
 
413
479
  def read_messages(&block)
@@ -418,43 +484,59 @@ module ClaudeAgentSDK
418
484
  json_buffer = ''
419
485
 
420
486
  begin
421
- @stdout.each_line do |line|
422
- line_str = line.strip
423
- next if line_str.empty?
424
-
425
- json_lines = line_str.split("\n")
426
-
427
- json_lines.each do |json_line|
428
- json_line = json_line.strip
429
- next if json_line.empty?
430
-
431
- # When no partial JSON is buffered, the next line must start with
432
- # `{` to be a valid stream-json message. Stray stderr-like text
487
+ # The limit bounds per-read allocation: a line longer than
488
+ # max_buffer_size+1 arrives as bounded chunks that the existing
489
+ # accumulation + cap machinery below handles (mirrors Python, where
490
+ # TextReceiveStream yields <=64KB chunks and the cap fires
491
+ # incrementally). +1 so an exactly-max line plus "\n" arrives whole.
492
+ # With UTF-8 external encoding Ruby extends a few bytes past the
493
+ # limit rather than splitting a multibyte char. Without the limit,
494
+ # an oversized line was fully allocated BEFORE the 1MB cap could
495
+ # fire — unbounded memory on hostile/buggy stdout.
496
+ @stdout.each_line("\n", @max_buffer_size + 1) do |line|
497
+ # Position-aware whitespace handling: a chunk of an over-limit line
498
+ # must keep its interior whitespace a blanket per-chunk strip
499
+ # deleted spaces inside JSON strings straddling the chunk boundary
500
+ # and could let a just-over-cap line PARSE with bytes silently
501
+ # missing instead of raising. Only safe edges are trimmed: full
502
+ # single-chunk lines strip both ends (the common path, original
503
+ # behavior); a truncated line-initial chunk keeps its tail; a
504
+ # continuation chunk keeps its head and only drops the newline.
505
+ ends_line = line.end_with?("\n")
506
+ if json_buffer.empty?
507
+ chunk = ends_line ? line.strip : line.lstrip
508
+ next if chunk.empty?
509
+
510
+ # When no partial JSON is buffered, the line must start with `{`
511
+ # to be a valid stream-json message. Stray stderr-like text
433
512
  # (e.g., debug warnings the CLI occasionally writes to stdout)
434
513
  # would otherwise be appended into json_buffer, poisoning every
435
514
  # subsequent parse until the buffer overflows. Matches the Python
436
- # SDK's `if not json_buffer and not json_line.startswith("{")` guard.
437
- next if json_buffer.empty? && !json_line.start_with?('{')
438
-
439
- json_buffer += json_line
440
-
441
- if json_buffer.bytesize > @max_buffer_size
442
- buffer_length = json_buffer.bytesize
443
- json_buffer = ''
444
- raise CLIJSONDecodeError.new(
445
- "JSON message exceeded maximum buffer size",
446
- StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
447
- )
448
- end
515
+ # SDK's `if not json_buffer and not json_line.startswith("{")`.
516
+ next unless chunk.start_with?('{')
517
+ else
518
+ chunk = ends_line ? line.chomp : line
519
+ end
449
520
 
450
- begin
451
- data = JSON.parse(json_buffer, symbolize_names: true)
452
- json_buffer = ''
453
- yield data
454
- rescue JSON::ParserError
455
- # Continue accumulating
456
- next
457
- end
521
+ json_buffer += chunk
522
+
523
+ if json_buffer.bytesize > @max_buffer_size
524
+ buffer_length = json_buffer.bytesize
525
+ json_buffer = ''
526
+ raise CLIJSONDecodeError.new(
527
+ "JSON message exceeded maximum buffer size",
528
+ StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
529
+ )
530
+ end
531
+
532
+ begin
533
+ data = JSON.parse(json_buffer, symbolize_names: true)
534
+ json_buffer = ''
535
+ yield data
536
+ rescue JSON::ParserError
537
+ # Continue accumulating (multi-line JSON, or a truncated chunk
538
+ # awaiting the rest of its line)
539
+ next
458
540
  end
459
541
  end
460
542
  rescue IOError
@@ -499,23 +581,34 @@ module ClaudeAgentSDK
499
581
  end
500
582
 
501
583
  def check_claude_version
584
+ # Mirrors Python's os.environ.get truthiness: any non-empty value skips,
585
+ # including '0'/'false'/' '; unset or empty string runs the check.
586
+ skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil)
587
+ return if skip && !skip.empty?
588
+
502
589
  begin
503
- stdout, stderr, = Open3.capture3(@cli_path.to_s, '-v')
504
- output = (stdout.to_s + stderr.to_s).strip
590
+ output = capture_cli_version_output
591
+ # Residual divergence from Python (anchored re.match over the first
592
+ # stdout chunk): this searches anywhere in stdout+stderr, so leading
593
+ # noise (a shim's own version line) could be mistaken for the CLI
594
+ # version. Pre-existing shape; the check is best-effort only.
505
595
  if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
506
596
  version = match[1]
507
597
  version_parts = version.split('.').map(&:to_i)
508
598
  min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
509
599
 
510
- if version_parts < min_parts
511
- warning = "Warning: Claude Code version #{version} is unsupported in the Agent SDK. " \
600
+ # Array has no #< — the old `version_parts < min_parts` raised
601
+ # NoMethodError into the blanket rescue, so the warning never fired.
602
+ if (version_parts <=> min_parts).negative?
603
+ warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
512
604
  "Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
513
605
  "Some features may not work correctly."
514
606
  warn warning
515
607
  end
516
608
  end
517
609
  rescue StandardError
518
- # Ignore version check errors
610
+ # Ignore version check errors — including Timeout::Error from the
611
+ # probe deadline, mirroring Python's `except Exception: pass`.
519
612
  end
520
613
  end
521
614
 
@@ -525,6 +618,47 @@ module ClaudeAgentSDK
525
618
 
526
619
  private
527
620
 
621
+ # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same
622
+ # injection-safety as capture3. Raises Timeout::Error past
623
+ # VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's
624
+ # blanket rescue, mirroring Python's `except Exception: pass` around
625
+ # anyio.fail_after(2)). Monotonic-deadline poll instead of stdlib
626
+ # Timeout.timeout for the same reason as wait_process_with_timeout:
627
+ # Thread#raise corrupts Async fiber-scheduler state, and connect runs
628
+ # inside the reactor. Divergence: Python takes a single stdout chunk; we
629
+ # read both pipes to EOF (pre-existing capture3 shape), so the deadline
630
+ # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
631
+ # finally: terminate(); wait()).
632
+ def capture_cli_version_output
633
+ stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
634
+ stdin.close
635
+ drainer = Thread.new { [stdout.read, stderr.read] }
636
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS
637
+ task = defined?(Async::Task) ? Async::Task.current? : nil
638
+ until drainer.join(0)
639
+ raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
640
+
641
+ task ? task.sleep(0.05) : sleep(0.05)
642
+ end
643
+ out, err = drainer.value
644
+ (out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip
645
+ ensure
646
+ if wait_thr&.alive?
647
+ begin
648
+ Process.kill('TERM', wait_thr.pid)
649
+ Process.kill('KILL', wait_thr.pid) if !wait_thr.join(0.5) && wait_thr.alive?
650
+ rescue StandardError
651
+ # ESRCH etc. — probe already gone
652
+ end
653
+ end
654
+ drainer&.kill if drainer&.alive?
655
+ [stdout, stderr].each do |io|
656
+ io&.close
657
+ rescue StandardError
658
+ # already closed
659
+ end
660
+ end
661
+
528
662
  # Append a stderr line to the recent-stderr ring, dropping the oldest
529
663
  # entry once the buffer exceeds RECENT_STDERR_LINES_LIMIT. Used to surface the
530
664
  # last few lines in ProcessError when the CLI exits non-zero.
@@ -15,6 +15,17 @@ module ClaudeAgentSDK
15
15
 
16
16
  # Assert the 15 SessionStore behavioral contracts against an adapter.
17
17
  #
18
+ # Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
19
+ # Contract 15 is a Ruby SDK extension locking empty-subpath delete
20
+ # coherence ('' == no subpath, the same addressing append/load already
21
+ # use in every implementation in both SDKs); it runs only for stores
22
+ # implementing #delete, and skip_optional: %w[delete] excludes the
23
+ # delete contracts wholesale. Note: a store ported 1:1 from Python's
24
+ # reference patterns that gates its delete cascade on
25
+ # `subpath is not None` will fail contract 15 — that pattern orphans
26
+ # subkeys (a known upstream incoherence; Python's own postgres example
27
+ # passes while its redis/s3 examples fail).
28
+ #
18
29
  # Framework-agnostic: raises ConformanceError on the first violated
19
30
  # contract, otherwise returns nil. Call it from any test framework, e.g.
20
31
  #
@@ -205,7 +216,10 @@ module ClaudeAgentSDK
205
216
  [key, sub1, sub2].each { |k| store.append(k, [entry('n' => 1)]) }
206
217
  store.delete(key.merge('subpath' => ''))
207
218
  assert(store.load(key).nil?, 'delete with empty subpath must remove the main transcript')
208
- assert(store.load(sub1).nil?, 'delete with empty subpath must cascade to subkeys')
219
+ assert(store.load(sub1).nil?,
220
+ 'delete with empty subpath must cascade to subkeys ' \
221
+ '(gate the cascade on sub.nil? || sub.empty? — a nil?-only gate, ' \
222
+ 'the direct port of Python\'s `is not None`, orphans subkeys)')
209
223
  if has_list_sessions
210
224
  listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
211
225
  assert(!listed.include?(key['session_id']), 'session deleted via empty subpath must not be listed')