claude-agent-sdk 0.36.0 → 0.37.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +2 -2
  4. data/docs/client.md +26 -1
  5. data/docs/hooks-and-permissions.md +5 -3
  6. data/docs/mcp-servers.md +1 -2
  7. data/docs/sessions.md +81 -2
  8. data/docs/types.md +106 -4
  9. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  10. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  11. data/lib/claude_agent_sdk/deprecation.rb +39 -0
  12. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  13. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  14. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  15. data/lib/claude_agent_sdk/observer.rb +2 -1
  16. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  17. data/lib/claude_agent_sdk/query.rb +50 -43
  18. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  19. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  20. data/lib/claude_agent_sdk/session_resume.rb +20 -11
  21. data/lib/claude_agent_sdk/session_store.rb +7 -3
  22. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  23. data/lib/claude_agent_sdk/sessions.rb +8 -6
  24. data/lib/claude_agent_sdk/streaming.rb +1 -1
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  26. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  27. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  28. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  29. data/lib/claude_agent_sdk/types/base.rb +320 -0
  30. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  31. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  32. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  33. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  34. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  35. data/lib/claude_agent_sdk/types/options.rb +352 -0
  36. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  37. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  38. data/lib/claude_agent_sdk/types.rb +13 -2534
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +51 -17
  41. metadata +11 -1
@@ -56,8 +56,8 @@ module ClaudeAgentSDK
56
56
  # mode) and a cancelled append may remain permanently half-applied in the
57
57
  # store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
58
58
  # the local transcript remains the source of truth; the
59
- # dedupe-by-entry-uuid recommendation above stays advisory. The method is deliberately NOT defined here: the SDK probes
60
- # `respond_to?(:callback_scheduling)` (see
59
+ # dedupe-by-entry-uuid recommendation above stays advisory. The method is
60
+ # deliberately NOT defined here: the SDK probes `respond_to?(:callback_scheduling)` (see
61
61
  # SessionStores.store_callback_scheduling), so pure duck-typed adapters
62
62
  # stay minimal, and an app can opt a third-party fiber-native adapter in
63
63
  # via a singleton method (`def store.callback_scheduling = :inline`).
@@ -290,6 +290,8 @@ module ClaudeAgentSDK
290
290
  end
291
291
 
292
292
  # Internal SessionStore support functions (path mapping, option validation).
293
+ #
294
+ # @api private
293
295
  module SessionStores
294
296
  STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
295
297
 
@@ -349,7 +351,9 @@ module ClaudeAgentSDK
349
351
  second = parts[1]
350
352
 
351
353
  # Main transcript: <project_key>/<session_id>.jsonl
352
- return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')
354
+ if parts.length == 2 && second.end_with?('.jsonl')
355
+ return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') }
356
+ end
353
357
 
354
358
  # Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
355
359
  if parts.length >= 4
@@ -16,6 +16,8 @@ module ClaudeAgentSDK
16
16
  # (string keys from JSON), and the summary's opaque +data+ dict is persisted
17
17
  # verbatim by adapters — string keys survive a JSON round-trip (Postgres
18
18
  # JSONB, Redis) losslessly, whereas symbol keys would not.
19
+ #
20
+ # @api private
19
21
  module SessionSummary
20
22
  # JSONL entry keys -> summary data keys for last-wins string fields. Each
21
23
  # appended entry overwrites the previous value when present.
@@ -49,7 +51,7 @@ module ClaudeAgentSDK
49
51
  # @param key [Hash] the SessionKey (string keys)
50
52
  # @param entries [Array<Hash>] newly appended transcript entries
51
53
  # @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
52
- def fold_session_summary(prev, key, entries)
54
+ def fold_session_summary(prev, key, entries) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- incremental fold over every summary field
53
55
  summary = if prev
54
56
  { 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
55
57
  else
@@ -147,7 +149,7 @@ module ClaudeAgentSDK
147
149
  # deliberately match the disk extractor (not Python's per-char replace) so
148
150
  # the Ruby store path and disk path produce identical first_prompt values
149
151
  # for the same transcript.
150
- def fold_first_prompt(data, entry)
152
+ def fold_first_prompt(data, entry) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- must match the disk first-prompt extractor rule for rule
151
153
  return if data['first_prompt_locked']
152
154
  return unless entry['type'] == 'user'
153
155
  return if entry['isMeta'] == true || entry['isCompactSummary'] == true
@@ -83,7 +83,9 @@ module ClaudeAgentSDK
83
83
  end
84
84
 
85
85
  # Session browsing functions
86
- module Sessions # rubocop:disable Metrics/ModuleLength
86
+ #
87
+ # @api private
88
+ module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/reading functions share private helpers
87
89
  LITE_READ_BUF_SIZE = 65_536
88
90
  MAX_SANITIZED_LENGTH = 200
89
91
 
@@ -435,7 +437,7 @@ module ClaudeAgentSDK
435
437
  end
436
438
 
437
439
  # Extract the first meaningful user prompt from the head of a JSONL file
438
- def extract_first_prompt_from_head(head)
440
+ def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold
439
441
  command_fallback = nil
440
442
 
441
443
  head.each_line do |line|
@@ -547,7 +549,7 @@ module ClaudeAgentSDK
547
549
  [head, tail]
548
550
  end
549
551
 
550
- def build_session_info(file_path, head, tail, stat, project_path)
552
+ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute
551
553
  # User-set title (customTitle) wins over AI-generated title (aiTitle).
552
554
  # Consult the head only when the tail has no occurrence of that field.
553
555
  # Normalize blanks AFTER choosing the latest occurrence: an explicit
@@ -998,7 +1000,7 @@ module ClaudeAgentSDK
998
1000
  # NotImplementedError (caller falls back to the slow path). Sessions missing
999
1001
  # a sidecar or whose sidecar is stale (summary.mtime < the session's current
1000
1002
  # mtime) are routed through gap-fill so the fold is recomputed from source.
1001
- def list_sessions_via_summaries(store, project_key, project_path, limit, offset)
1003
+ def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill
1002
1004
  begin
1003
1005
  # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read)
1004
1006
  # degrades to gap-fill instead of crashing on nil.each, matching the
@@ -1046,7 +1048,7 @@ module ClaudeAgentSDK
1046
1048
  # leaves a short page; loads stay bounded to ~offset + limit + (the dropped
1047
1049
  # placeholders encountered before the page fills), preserving the fast
1048
1050
  # path's "don't load every session" intent.
1049
- def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset)
1051
+ def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset) # rubocop:disable Metrics/ParameterLists -- pagination state threaded explicitly
1050
1052
  offset = 0 unless offset&.positive?
1051
1053
  results = []
1052
1054
  skipped = 0
@@ -1399,7 +1401,7 @@ module ClaudeAgentSDK
1399
1401
  # threads (so a full pipe buffer can't deadlock git) and SIGKILL the
1400
1402
  # child if the deadline passes. Matches Python's
1401
1403
  # `subprocess.run(..., timeout=5)`.
1402
- def detect_worktrees(path)
1404
+ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill
1403
1405
  stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
1404
1406
  stdin.close
1405
1407
 
@@ -21,7 +21,7 @@ module ClaudeAgentSDK
21
21
  parent_tool_use_id: parent_tool_use_id,
22
22
  session_id: session_id
23
23
  }
24
- JSON.generate(message) + "\n"
24
+ "#{JSON.generate(message)}\n"
25
25
  end
26
26
 
27
27
  # Create an Enumerator from an array of messages
@@ -11,17 +11,26 @@ require_relative 'cli_installer'
11
11
 
12
12
  module ClaudeAgentSDK
13
13
  # Subprocess transport using Claude Code CLI
14
- class SubprocessCLITransport < Transport
14
+ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength -- subprocess lifecycle: discovery, spawn, IO, teardown
15
+ # @api private
15
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
+ # @api private
16
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # @api private
17
20
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
+ # @api private
18
22
  CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
23
+ # @api private
19
24
  VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
25
+ # @api private
20
26
  RECENT_STDERR_LINES_LIMIT = 20
21
27
  # After stdout EOF the child has closed (or lost) its last stdout handle,
22
28
  # so it is normally already exiting; a CLI still running this long
23
29
  # afterwards is wedged and gets the same TERM -> KILL ladder as #close.
30
+ #
31
+ # @api private
24
32
  EOF_EXIT_GRACE_SECONDS = 5
33
+ # @api private
25
34
  EOF_TERM_GRACE_SECONDS = 2
26
35
 
27
36
  # Track live CLI subprocesses so we can terminate them when the parent Ruby
@@ -39,27 +48,36 @@ module ClaudeAgentSDK
39
48
  # `self.class.register_active_process` would otherwise reach a nil mutex and
40
49
  # raise mid-#connect, orphaning the just-spawned child. The base-class
41
50
  # at_exit handler must be able to see every subprocess, a subclass's too.
51
+ #
52
+ # @api private
42
53
  ACTIVE_PROCESSES = Set.new
54
+ # @api private
43
55
  ACTIVE_PROCESSES_MUTEX = Mutex.new
44
56
 
45
57
  class << self
46
58
  # Public readers (the test suite uses `described_class.active_processes`);
47
59
  # they return the shared constants so subclasses observe the same objects.
60
+ #
61
+ # @api private
48
62
  def active_processes
49
63
  ACTIVE_PROCESSES
50
64
  end
51
65
 
66
+ # @api private
52
67
  def active_processes_mutex
53
68
  ACTIVE_PROCESSES_MUTEX
54
69
  end
55
70
 
56
71
  # +wait_thr+ is the Process::Waiter returned by Open3.popen3.
72
+ #
73
+ # @api private
57
74
  def register_active_process(wait_thr)
58
75
  return unless wait_thr
59
76
 
60
77
  active_processes_mutex.synchronize { active_processes.add(wait_thr) }
61
78
  end
62
79
 
80
+ # @api private
63
81
  def deregister_active_process(wait_thr)
64
82
  return unless wait_thr
65
83
 
@@ -79,6 +97,8 @@ module ClaudeAgentSDK
79
97
  # raises (e.g. ThreadError if reached from a trap context, or a
80
98
  # concurrent-modification error from the unlocked read), honoring the
81
99
  # "never interrupt interpreter shutdown" contract.
100
+ #
101
+ # @api private
82
102
  def kill_active_processes
83
103
  active_processes.to_a.each do |wait_thr|
84
104
  next unless wait_thr.alive?
@@ -94,6 +114,7 @@ module ClaudeAgentSDK
94
114
  end
95
115
 
96
116
  def initialize(options_or_prompt = nil, options = nil)
117
+ super() # Transport defines no state today; keep the chain intact if it ever does
97
118
  # Support both new single-arg form and legacy two-arg form
98
119
  @options = options.nil? ? options_or_prompt : options
99
120
  @cli_path = @options.cli_path || find_cli
@@ -134,7 +155,9 @@ module ClaudeAgentSDK
134
155
  # whatever version happens to be installed globally on the host.
135
156
  # 3. `which claude`.
136
157
  # 4. Well-known install locations.
137
- def find_cli
158
+ #
159
+ # @api private
160
+ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations)
138
161
  env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
139
162
  unless env_path.empty?
140
163
  # Absolutize against the CURRENT working directory, which is where the
@@ -188,18 +211,17 @@ module ClaudeAgentSDK
188
211
  return path if File.file?(path) && File.executable?(path)
189
212
  end
190
213
 
191
- raise CLINotFoundError.new(
192
- "Claude Code not found. Install with:\n" \
193
- " npm install -g @anthropic-ai/claude-code\n" \
194
- "\nIf already installed locally, try:\n" \
195
- ' export PATH="$HOME/node_modules/.bin:$PATH"' \
196
- "\n\nOr provide the path via ClaudeAgentOptions:\n" \
197
- " ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
198
- "\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n" \
199
- " ClaudeAgentSDK::CLIInstaller.install_pinned # installs #{CLIInstaller::PINNED_CLI_VERSION}" \
200
- "\n\nOr point the SDK at an existing binary:\n" \
201
- " export #{CLI_PATH_ENV_VAR}=/path/to/claude"
202
- )
214
+ raise CLINotFoundError,
215
+ "Claude Code not found. Install with:\n " \
216
+ "npm install -g @anthropic-ai/claude-code\n" \
217
+ "\nIf already installed locally, try:\n " \
218
+ 'export PATH="$HOME/node_modules/.bin:$PATH"' \
219
+ "\n\nOr provide the path via ClaudeAgentOptions:\n " \
220
+ "ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
221
+ "\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n " \
222
+ "ClaudeAgentSDK::CLIInstaller.install_pinned # installs #{CLIInstaller::PINNED_CLI_VERSION}" \
223
+ "\n\nOr point the SDK at an existing binary:\n " \
224
+ "export #{CLI_PATH_ENV_VAR}=/path/to/claude"
203
225
  end
204
226
 
205
227
  # Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
@@ -209,6 +231,8 @@ module ClaudeAgentSDK
209
231
  # group. Gate on the carrier's traceparent key (the W3C propagator writes
210
232
  # it only for a valid span context) so a baggage-only carrier or a noop
211
233
  # propagator preserves inherited env.
234
+ #
235
+ # @api private
212
236
  def inject_otel_trace_context(process_env, custom_env)
213
237
  return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
214
238
 
@@ -233,11 +257,12 @@ module ClaudeAgentSDK
233
257
  # Exception). ScriptError too: NotImplementedError < ScriptError.
234
258
  end
235
259
 
260
+ # @api private
236
261
  def build_command
237
262
  CommandBuilder.new(@cli_path, @options).build
238
263
  end
239
264
 
240
- def connect
265
+ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
241
266
  return if @process
242
267
 
243
268
  check_claude_version
@@ -246,7 +271,7 @@ module ClaudeAgentSDK
246
271
 
247
272
  # Build environment
248
273
  # Convert symbol keys to strings for spawn compatibility
249
- custom_env = @options.env.transform_keys { |k| k.to_s }
274
+ custom_env = @options.env.transform_keys(&:to_s)
250
275
  # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
251
276
  # launches Claude Code from within an existing Claude Code terminal.
252
277
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
@@ -312,7 +337,7 @@ module ClaudeAgentSDK
312
337
 
313
338
  # Always keep stdin open — streaming mode uses it for the control protocol
314
339
  @ready = true
315
- rescue Errno::ENOENT => e
340
+ rescue Errno::ENOENT
316
341
  # Check if error is from cwd or CLI
317
342
  if @cwd && !File.directory?(@cwd.to_s)
318
343
  error = CLIConnectionError.new("Working directory does not exist: #{@cwd}")
@@ -332,6 +357,7 @@ module ClaudeAgentSDK
332
357
  end
333
358
  end
334
359
 
360
+ # @api private
335
361
  def handle_stderr
336
362
  return unless @stderr
337
363
 
@@ -375,6 +401,7 @@ module ClaudeAgentSDK
375
401
  # Stream-level error (pipe closed mid-read); the loop naturally ends here.
376
402
  end
377
403
 
404
+ # @api private
378
405
  def drain_stderr_with_accumulation
379
406
  return unless @stderr
380
407
 
@@ -387,7 +414,7 @@ module ClaudeAgentSDK
387
414
  end
388
415
  end
389
416
 
390
- def close
417
+ def close # rubocop:disable Metrics/MethodLength -- teardown ordering is load-bearing
391
418
  @ready = false
392
419
  return unless @process
393
420
 
@@ -465,7 +492,9 @@ module ClaudeAgentSDK
465
492
  # graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
466
493
  # the reactor and suspends at several points; #close's ensure covers the
467
494
  # cancellation-abandoned case.
468
- def teardown_process
495
+ #
496
+ # @api private
497
+ def teardown_process # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- TERM/KILL escalation and reaping; ordering is load-bearing
469
498
  cleanup_errors = []
470
499
 
471
500
  # Kill stderr thread
@@ -543,9 +572,7 @@ module ClaudeAgentSDK
543
572
  end
544
573
 
545
574
  # Log any cleanup errors (non-fatal)
546
- if cleanup_errors.any?
547
- warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}"
548
- end
575
+ warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}" if cleanup_errors.any?
549
576
 
550
577
  self.class.deregister_active_process(@process)
551
578
  end
@@ -558,6 +585,8 @@ module ClaudeAgentSDK
558
585
  # left either way. The alive? guard also makes the delayed KILL
559
586
  # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
560
587
  # the pid cannot have been recycled.
588
+ #
589
+ # @api private
561
590
  def force_terminate_in_background(process, grace_seconds: 2)
562
591
  return unless process
563
592
 
@@ -576,20 +605,18 @@ module ClaudeAgentSDK
576
605
  end
577
606
 
578
607
  Thread.new do
579
- begin
580
- unless process.join(grace_seconds)
581
- begin
582
- Process.kill('KILL', pid) if process.alive?
583
- rescue Errno::ESRCH
584
- # Still wait for the waiter when exit raced the signal.
585
- end
586
- process.join(grace_seconds)
608
+ unless process.join(grace_seconds)
609
+ begin
610
+ Process.kill('KILL', pid) if process.alive?
611
+ rescue Errno::ESRCH
612
+ # Still wait for the waiter when exit raced the signal.
587
613
  end
588
- rescue StandardError
589
- nil # best-effort; retain ownership if termination/reaping failed
590
- ensure
591
- self.class.deregister_active_process(process) unless process.alive?
614
+ process.join(grace_seconds)
592
615
  end
616
+ rescue StandardError
617
+ nil # best-effort; retain ownership if termination/reaping failed
618
+ ensure
619
+ self.class.deregister_active_process(process) unless process.alive?
593
620
  end
594
621
  end
595
622
 
@@ -598,6 +625,8 @@ module ClaudeAgentSDK
598
625
  # across threads via Thread#raise and corrupts Async fiber-scheduler state
599
626
  # (close is always called inside an Async task). Yields to the current
600
627
  # Async task when one is active so the reactor keeps running.
628
+ #
629
+ # @api private
601
630
  def wait_process_with_timeout(timeout_seconds, process = @process)
602
631
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
603
632
  task = defined?(Async::Task) ? Async::Task.current? : nil
@@ -613,6 +642,8 @@ module ClaudeAgentSDK
613
642
  # Process::Waiter#join(timeout): under a Fiber scheduler Ruby 3.2's
614
643
  # Thread#join ignores its timeout and never returns for a live thread
615
644
  # (probed on 3.2.0; 3.3/3.4 honor it).
645
+ #
646
+ # @api private
616
647
  def process_exited_within?(process, seconds)
617
648
  wait_process_with_timeout(seconds, process)
618
649
  true
@@ -621,7 +652,7 @@ module ClaudeAgentSDK
621
652
  end
622
653
 
623
654
  def write(data)
624
- raise CLIConnectionError, "Cannot write to terminated process" if @process && !@process.alive?
655
+ raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
625
656
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
626
657
 
627
658
  # Snapshot @stdin under the lock so close() nilling it concurrently is
@@ -688,7 +719,7 @@ module ClaudeAgentSDK
688
719
  # Ignore
689
720
  end
690
721
 
691
- def read_messages(&)
722
+ def read_messages(&) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
692
723
  return enum_for(:read_messages) unless block_given?
693
724
 
694
725
  raise CLIConnectionError, 'Not connected' unless @process && @stdout
@@ -747,7 +778,7 @@ module ClaudeAgentSDK
747
778
  buffer_length = json_buffer.bytesize
748
779
  json_buffer = ''
749
780
  raise CLIJSONDecodeError.new(
750
- "JSON message exceeded maximum buffer size",
781
+ 'JSON message exceeded maximum buffer size',
751
782
  StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
752
783
  )
753
784
  end
@@ -865,6 +896,7 @@ module ClaudeAgentSDK
865
896
  )
866
897
  end
867
898
 
899
+ # @api private
868
900
  def check_claude_version
869
901
  # Mirrors Python's os.environ.get truthiness: any non-empty value skips,
870
902
  # including '0'/'false'/' '; unset or empty string runs the check.
@@ -877,7 +909,7 @@ module ClaudeAgentSDK
877
909
  # stdout chunk): this searches anywhere in stdout+stderr, so leading
878
910
  # noise (a shim's own version line) could be mistaken for the CLI
879
911
  # version. Pre-existing shape; the check is best-effort only.
880
- if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
912
+ if (match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/))
881
913
  version = match[1]
882
914
  version_parts = version.split('.').map(&:to_i)
883
915
  min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
@@ -887,7 +919,7 @@ module ClaudeAgentSDK
887
919
  if (version_parts <=> min_parts).negative?
888
920
  warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
889
921
  "Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
890
- "Some features may not work correctly."
922
+ 'Some features may not work correctly.'
891
923
  warn warning
892
924
  end
893
925
  end
@@ -914,7 +946,7 @@ module ClaudeAgentSDK
914
946
  # read both pipes to EOF (pre-existing capture3 shape), so the deadline
915
947
  # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
916
948
  # finally: terminate(); wait()).
917
- def capture_cli_version_output
949
+ def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping
918
950
  stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
919
951
  stdin.close
920
952
  drainer = Thread.new { [stdout.read, stderr.read] }
@@ -5,7 +5,7 @@ require_relative '../session_summary'
5
5
 
6
6
  module ClaudeAgentSDK
7
7
  # Test helpers shipped in the gem for third-party SessionStore adapter authors.
8
- module Testing # rubocop:disable Metrics/ModuleLength
8
+ module Testing # rubocop:disable Metrics/ModuleLength -- the whole conformance suite in one module
9
9
  # Raised by run_session_store_conformance when a behavioral contract fails.
10
10
  class ConformanceError < StandardError; end
11
11
 
@@ -100,7 +100,7 @@ module ClaudeAgentSDK
100
100
 
101
101
  # -- Required: append + load -------------------------------------------
102
102
 
103
- def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
103
+ def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- linear assertion script
104
104
  # 1. append then load returns same entries in same order.
105
105
  store = fresh.call
106
106
  store.append(key, [entry('uuid' => 'b', 'n' => 1), entry('uuid' => 'a', 'n' => 2)])
@@ -165,7 +165,7 @@ module ClaudeAgentSDK
165
165
 
166
166
  # -- Optional: list_sessions -------------------------------------------
167
167
 
168
- def check_list_sessions(fresh)
168
+ def check_list_sessions(fresh) # rubocop:disable Metrics/AbcSize -- linear assertion script
169
169
  # 7. list_sessions returns session_ids for project.
170
170
  store = fresh.call
171
171
  store.append({ 'project_key' => 'proj', 'session_id' => 'a' }, [entry('n' => 1)])
@@ -197,7 +197,7 @@ module ClaudeAgentSDK
197
197
 
198
198
  # -- Optional: list_session_summaries ----------------------------------
199
199
 
200
- def check_list_session_summaries(fresh, has_list_sessions, has_delete) # rubocop:disable Metrics/MethodLength
200
+ def check_list_session_summaries(fresh, has_list_sessions, has_delete) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- linear assertion script
201
201
  # 14. persisted fold output round-trips through fold_session_summary.
202
202
  store = fresh.call
203
203
  summ_key = { 'project_key' => 'proj', 'session_id' => 'summ-sess' }
@@ -236,8 +236,10 @@ module ClaudeAgentSDK
236
236
  # Subagent appends must NOT affect the main session's summary.
237
237
  store.append(summ_key.merge('subpath' => 'subagents/agent-1'),
238
238
  [entry('timestamp' => '2024-01-01T00:00:09.000Z', 'customTitle' => 'subagent')])
239
- after_sub = summaries_by_id(store, 'proj', ['summ-sess'],
240
- 'list_session_summaries must still return one row per session after a subagent append')
239
+ after_sub = summaries_by_id(
240
+ store, 'proj', ['summ-sess'],
241
+ 'list_session_summaries must still return one row per session after a subagent append'
242
+ )
241
243
  assert_eq(after_sub['summ-sess']['data'], summ['data'], 'subagent appends must not change the main summary')
242
244
  assert_eq(store.list_session_summaries('never-appended-project'), [], 'unknown project must list no summaries')
243
245
 
@@ -249,7 +251,7 @@ module ClaudeAgentSDK
249
251
 
250
252
  # -- Optional: delete --------------------------------------------------
251
253
 
252
- def check_delete(fresh, has_list_subkeys, has_list_sessions) # rubocop:disable Metrics/MethodLength
254
+ def check_delete(fresh, has_list_subkeys, has_list_sessions) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- linear assertion script
253
255
  # 9. delete main then load returns nil (delete of never-written is a no-op).
254
256
  store = fresh.call
255
257
  store.delete('project_key' => 'proj', 'session_id' => 'never-written')
@@ -308,7 +310,7 @@ module ClaudeAgentSDK
308
310
 
309
311
  # -- Optional: list_subkeys --------------------------------------------
310
312
 
311
- def check_list_subkeys(fresh)
313
+ def check_list_subkeys(fresh) # rubocop:disable Metrics/AbcSize -- linear assertion script
312
314
  # 12. list_subkeys returns subpaths (scoped to the session).
313
315
  store = fresh.call
314
316
  store.append(key, [entry('n' => 1)])
@@ -317,7 +319,8 @@ module ClaudeAgentSDK
317
319
  store.append({ 'project_key' => key['project_key'], 'session_id' => 'other-sess',
318
320
  'subpath' => 'subagents/agent-x' }, [entry('n' => 1)])
319
321
  subkeys = store.list_subkeys(key)
320
- assert_eq(subkeys.sort, ['subagents/agent-1', 'subagents/agent-2'], "list_subkeys must return this session's subpaths")
322
+ assert_eq(subkeys.sort, ['subagents/agent-1', 'subagents/agent-2'],
323
+ "list_subkeys must return this session's subpaths")
321
324
  assert(!subkeys.include?('subagents/agent-x'), "list_subkeys must not leak another session's subkeys")
322
325
 
323
326
  # 13. list_subkeys excludes the main transcript.
@@ -381,7 +384,8 @@ module ClaudeAgentSDK
381
384
  return if actual == expected
382
385
 
383
386
  raise ConformanceError,
384
- "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
387
+ "SessionStore conformance failed: #{message}\n " \
388
+ "expected: #{expected.inspect}\n actual: #{actual.inspect}"
385
389
  end
386
390
 
387
391
  private_class_method :check_callback_scheduling_declaration, :check_append_and_load, :check_list_sessions,
@@ -44,6 +44,8 @@ module ClaudeAgentSDK
44
44
  # may remain permanently HALF-applied in the store. The drop is surfaced
45
45
  # (MirrorErrorMessage, batches_dropped?); the local transcript remains the
46
46
  # source of truth.
47
+ #
48
+ # @api private
47
49
  class TranscriptMirrorBatcher
48
50
  # Eager-flush thresholds (exposed for tests).
49
51
  MAX_PENDING_ENTRIES = 500