claude-agent-sdk 0.35.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -1
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require 'open3'
5
- require 'set'
6
5
  require 'timeout'
7
6
  require_relative 'transport'
8
7
  require_relative 'errors'
@@ -12,17 +11,26 @@ require_relative 'cli_installer'
12
11
 
13
12
  module ClaudeAgentSDK
14
13
  # Subprocess transport using Claude Code CLI
15
- class SubprocessCLITransport < Transport
14
+ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength -- subprocess lifecycle: discovery, spawn, IO, teardown
15
+ # @api private
16
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
+ # @api private
17
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # @api private
18
20
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
+ # @api private
19
22
  CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
23
+ # @api private
20
24
  VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
25
+ # @api private
21
26
  RECENT_STDERR_LINES_LIMIT = 20
22
27
  # After stdout EOF the child has closed (or lost) its last stdout handle,
23
28
  # so it is normally already exiting; a CLI still running this long
24
29
  # afterwards is wedged and gets the same TERM -> KILL ladder as #close.
30
+ #
31
+ # @api private
25
32
  EOF_EXIT_GRACE_SECONDS = 5
33
+ # @api private
26
34
  EOF_TERM_GRACE_SECONDS = 2
27
35
 
28
36
  # Track live CLI subprocesses so we can terminate them when the parent Ruby
@@ -40,27 +48,36 @@ module ClaudeAgentSDK
40
48
  # `self.class.register_active_process` would otherwise reach a nil mutex and
41
49
  # raise mid-#connect, orphaning the just-spawned child. The base-class
42
50
  # at_exit handler must be able to see every subprocess, a subclass's too.
51
+ #
52
+ # @api private
43
53
  ACTIVE_PROCESSES = Set.new
54
+ # @api private
44
55
  ACTIVE_PROCESSES_MUTEX = Mutex.new
45
56
 
46
57
  class << self
47
58
  # Public readers (the test suite uses `described_class.active_processes`);
48
59
  # they return the shared constants so subclasses observe the same objects.
60
+ #
61
+ # @api private
49
62
  def active_processes
50
63
  ACTIVE_PROCESSES
51
64
  end
52
65
 
66
+ # @api private
53
67
  def active_processes_mutex
54
68
  ACTIVE_PROCESSES_MUTEX
55
69
  end
56
70
 
57
71
  # +wait_thr+ is the Process::Waiter returned by Open3.popen3.
72
+ #
73
+ # @api private
58
74
  def register_active_process(wait_thr)
59
75
  return unless wait_thr
60
76
 
61
77
  active_processes_mutex.synchronize { active_processes.add(wait_thr) }
62
78
  end
63
79
 
80
+ # @api private
64
81
  def deregister_active_process(wait_thr)
65
82
  return unless wait_thr
66
83
 
@@ -80,6 +97,8 @@ module ClaudeAgentSDK
80
97
  # raises (e.g. ThreadError if reached from a trap context, or a
81
98
  # concurrent-modification error from the unlocked read), honoring the
82
99
  # "never interrupt interpreter shutdown" contract.
100
+ #
101
+ # @api private
83
102
  def kill_active_processes
84
103
  active_processes.to_a.each do |wait_thr|
85
104
  next unless wait_thr.alive?
@@ -95,6 +114,7 @@ module ClaudeAgentSDK
95
114
  end
96
115
 
97
116
  def initialize(options_or_prompt = nil, options = nil)
117
+ super() # Transport defines no state today; keep the chain intact if it ever does
98
118
  # Support both new single-arg form and legacy two-arg form
99
119
  @options = options.nil? ? options_or_prompt : options
100
120
  @cli_path = @options.cli_path || find_cli
@@ -135,7 +155,9 @@ module ClaudeAgentSDK
135
155
  # whatever version happens to be installed globally on the host.
136
156
  # 3. `which claude`.
137
157
  # 4. Well-known install locations.
138
- 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)
139
161
  env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
140
162
  unless env_path.empty?
141
163
  # Absolutize against the CURRENT working directory, which is where the
@@ -189,18 +211,17 @@ module ClaudeAgentSDK
189
211
  return path if File.file?(path) && File.executable?(path)
190
212
  end
191
213
 
192
- raise CLINotFoundError.new(
193
- "Claude Code not found. Install with:\n" \
194
- " npm install -g @anthropic-ai/claude-code\n" \
195
- "\nIf already installed locally, try:\n" \
196
- ' export PATH="$HOME/node_modules/.bin:$PATH"' \
197
- "\n\nOr provide the path via ClaudeAgentOptions:\n" \
198
- " ClaudeAgentOptions.new(cli_path: '/path/to/claude')" \
199
- "\n\nFor hermetic deploys (Docker/CI), vendor a pinned CLI into the project:\n" \
200
- " ClaudeAgentSDK::CLIInstaller.install_pinned # installs #{CLIInstaller::PINNED_CLI_VERSION}" \
201
- "\n\nOr point the SDK at an existing binary:\n" \
202
- " export #{CLI_PATH_ENV_VAR}=/path/to/claude"
203
- )
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"
204
225
  end
205
226
 
206
227
  # Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
@@ -210,6 +231,8 @@ module ClaudeAgentSDK
210
231
  # group. Gate on the carrier's traceparent key (the W3C propagator writes
211
232
  # it only for a valid span context) so a baggage-only carrier or a noop
212
233
  # propagator preserves inherited env.
234
+ #
235
+ # @api private
213
236
  def inject_otel_trace_context(process_env, custom_env)
214
237
  return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
215
238
 
@@ -234,11 +257,12 @@ module ClaudeAgentSDK
234
257
  # Exception). ScriptError too: NotImplementedError < ScriptError.
235
258
  end
236
259
 
260
+ # @api private
237
261
  def build_command
238
262
  CommandBuilder.new(@cli_path, @options).build
239
263
  end
240
264
 
241
- def connect
265
+ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
242
266
  return if @process
243
267
 
244
268
  check_claude_version
@@ -247,7 +271,7 @@ module ClaudeAgentSDK
247
271
 
248
272
  # Build environment
249
273
  # Convert symbol keys to strings for spawn compatibility
250
- custom_env = @options.env.transform_keys { |k| k.to_s }
274
+ custom_env = @options.env.transform_keys(&:to_s)
251
275
  # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
252
276
  # launches Claude Code from within an existing Claude Code terminal.
253
277
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
@@ -313,7 +337,7 @@ module ClaudeAgentSDK
313
337
 
314
338
  # Always keep stdin open — streaming mode uses it for the control protocol
315
339
  @ready = true
316
- rescue Errno::ENOENT => e
340
+ rescue Errno::ENOENT
317
341
  # Check if error is from cwd or CLI
318
342
  if @cwd && !File.directory?(@cwd.to_s)
319
343
  error = CLIConnectionError.new("Working directory does not exist: #{@cwd}")
@@ -333,6 +357,7 @@ module ClaudeAgentSDK
333
357
  end
334
358
  end
335
359
 
360
+ # @api private
336
361
  def handle_stderr
337
362
  return unless @stderr
338
363
 
@@ -376,6 +401,7 @@ module ClaudeAgentSDK
376
401
  # Stream-level error (pipe closed mid-read); the loop naturally ends here.
377
402
  end
378
403
 
404
+ # @api private
379
405
  def drain_stderr_with_accumulation
380
406
  return unless @stderr
381
407
 
@@ -388,7 +414,7 @@ module ClaudeAgentSDK
388
414
  end
389
415
  end
390
416
 
391
- def close
417
+ def close # rubocop:disable Metrics/MethodLength -- teardown ordering is load-bearing
392
418
  @ready = false
393
419
  return unless @process
394
420
 
@@ -466,7 +492,9 @@ module ClaudeAgentSDK
466
492
  # graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
467
493
  # the reactor and suspends at several points; #close's ensure covers the
468
494
  # cancellation-abandoned case.
469
- 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
470
498
  cleanup_errors = []
471
499
 
472
500
  # Kill stderr thread
@@ -544,9 +572,7 @@ module ClaudeAgentSDK
544
572
  end
545
573
 
546
574
  # Log any cleanup errors (non-fatal)
547
- if cleanup_errors.any?
548
- warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}"
549
- end
575
+ warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}" if cleanup_errors.any?
550
576
 
551
577
  self.class.deregister_active_process(@process)
552
578
  end
@@ -559,6 +585,8 @@ module ClaudeAgentSDK
559
585
  # left either way. The alive? guard also makes the delayed KILL
560
586
  # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
561
587
  # the pid cannot have been recycled.
588
+ #
589
+ # @api private
562
590
  def force_terminate_in_background(process, grace_seconds: 2)
563
591
  return unless process
564
592
 
@@ -577,20 +605,18 @@ module ClaudeAgentSDK
577
605
  end
578
606
 
579
607
  Thread.new do
580
- begin
581
- unless process.join(grace_seconds)
582
- begin
583
- Process.kill('KILL', pid) if process.alive?
584
- rescue Errno::ESRCH
585
- # Still wait for the waiter when exit raced the signal.
586
- end
587
- 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.
588
613
  end
589
- rescue StandardError
590
- nil # best-effort; retain ownership if termination/reaping failed
591
- ensure
592
- self.class.deregister_active_process(process) unless process.alive?
614
+ process.join(grace_seconds)
593
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?
594
620
  end
595
621
  end
596
622
 
@@ -599,6 +625,8 @@ module ClaudeAgentSDK
599
625
  # across threads via Thread#raise and corrupts Async fiber-scheduler state
600
626
  # (close is always called inside an Async task). Yields to the current
601
627
  # Async task when one is active so the reactor keeps running.
628
+ #
629
+ # @api private
602
630
  def wait_process_with_timeout(timeout_seconds, process = @process)
603
631
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
604
632
  task = defined?(Async::Task) ? Async::Task.current? : nil
@@ -614,6 +642,8 @@ module ClaudeAgentSDK
614
642
  # Process::Waiter#join(timeout): under a Fiber scheduler Ruby 3.2's
615
643
  # Thread#join ignores its timeout and never returns for a live thread
616
644
  # (probed on 3.2.0; 3.3/3.4 honor it).
645
+ #
646
+ # @api private
617
647
  def process_exited_within?(process, seconds)
618
648
  wait_process_with_timeout(seconds, process)
619
649
  true
@@ -622,7 +652,7 @@ module ClaudeAgentSDK
622
652
  end
623
653
 
624
654
  def write(data)
625
- raise CLIConnectionError, "Cannot write to terminated process" if @process && !@process.alive?
655
+ raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
626
656
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
627
657
 
628
658
  # Snapshot @stdin under the lock so close() nilling it concurrently is
@@ -689,7 +719,7 @@ module ClaudeAgentSDK
689
719
  # Ignore
690
720
  end
691
721
 
692
- def read_messages(&block)
722
+ def read_messages(&) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
693
723
  return enum_for(:read_messages) unless block_given?
694
724
 
695
725
  raise CLIConnectionError, 'Not connected' unless @process && @stdout
@@ -748,7 +778,7 @@ module ClaudeAgentSDK
748
778
  buffer_length = json_buffer.bytesize
749
779
  json_buffer = ''
750
780
  raise CLIJSONDecodeError.new(
751
- "JSON message exceeded maximum buffer size",
781
+ 'JSON message exceeded maximum buffer size',
752
782
  StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
753
783
  )
754
784
  end
@@ -866,6 +896,7 @@ module ClaudeAgentSDK
866
896
  )
867
897
  end
868
898
 
899
+ # @api private
869
900
  def check_claude_version
870
901
  # Mirrors Python's os.environ.get truthiness: any non-empty value skips,
871
902
  # including '0'/'false'/' '; unset or empty string runs the check.
@@ -878,7 +909,7 @@ module ClaudeAgentSDK
878
909
  # stdout chunk): this searches anywhere in stdout+stderr, so leading
879
910
  # noise (a shim's own version line) could be mistaken for the CLI
880
911
  # version. Pre-existing shape; the check is best-effort only.
881
- if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
912
+ if (match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/))
882
913
  version = match[1]
883
914
  version_parts = version.split('.').map(&:to_i)
884
915
  min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
@@ -888,7 +919,7 @@ module ClaudeAgentSDK
888
919
  if (version_parts <=> min_parts).negative?
889
920
  warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
890
921
  "Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
891
- "Some features may not work correctly."
922
+ 'Some features may not work correctly.'
892
923
  warn warning
893
924
  end
894
925
  end
@@ -915,7 +946,7 @@ module ClaudeAgentSDK
915
946
  # read both pipes to EOF (pre-existing capture3 shape), so the deadline
916
947
  # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
917
948
  # finally: terminate(); wait()).
918
- 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
919
950
  stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
920
951
  stdin.close
921
952
  drainer = Thread.new { [stdout.read, stderr.read] }
@@ -1050,18 +1081,10 @@ module ClaudeAgentSDK
1050
1081
  end
1051
1082
  end
1052
1083
 
1053
- # The home directory for the well-known install probes, or nil when none
1054
- # is usable. Dir.home raises ArgumentError when HOME is unset and the uid
1055
- # has no passwd entry (docker --user in a minimal image), and returns an
1056
- # empty or relative HOME verbatim — probing under "" or a relative path
1057
- # would check files that are not where the user's install lives (and a
1058
- # relative hit would be spawned from options.cwd, i.e. a different file).
1059
- # SessionResume.home_dir applies the same rule.
1084
+ # The (parent's) home directory for the well-known install probes, or nil
1085
+ # when none is usable — see Sessions.home_dir, the one definition.
1060
1086
  def home_dir
1061
- home = Dir.home
1062
- home if File.absolute_path?(home)
1063
- rescue ArgumentError
1064
- nil
1087
+ Sessions.home_dir
1065
1088
  end
1066
1089
  end
1067
1090
  end
@@ -9,11 +9,18 @@ unless Rake::Task.task_defined?('claude_agent_sdk:install_cli')
9
9
  desc 'Install the Claude Code CLI into vendor/claude: the version this gem is tested with, ' \
10
10
  'or CLAUDE_CLI_VERSION=x.y.z / stable / latest'
11
11
  task :install_cli do
12
- # Under Rails, anchor to the app root instead of the process cwd (the
13
- # app is not booted: no :environment dependency, so this runs in a
14
- # Docker build without credentials). Resolved when the task runs.
15
- root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
16
- dir = root&.join('vendor', 'claude')&.to_s
12
+ # Install where discovery looks. An explicitly set CLIInstaller.root
13
+ # (config/application.rb, the Rakefile) wins: nil dir means
14
+ # CLIInstaller.default_dir, i.e. <root>/vendor/claude. Otherwise, under
15
+ # Rails, anchor to the app root instead of the process cwd — the same
16
+ # root the Railtie gives discovery at boot. The app is not booted here
17
+ # (no :environment dependency, so this runs in a Docker build without
18
+ # credentials), so that initializer has not run. Resolved when the
19
+ # task runs.
20
+ unless ClaudeAgentSDK::CLIInstaller.root
21
+ root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
22
+ dir = root&.join('vendor', 'claude')&.to_s
23
+ end
17
24
  # Not the conventional rake `VERSION`: Rails' own db:migrate uses it and
18
25
  # build environments often export it for the app's version or git SHA.
19
26
  version = ENV.fetch('CLAUDE_CLI_VERSION', '').strip
@@ -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.
@@ -360,7 +363,7 @@ module ClaudeAgentSDK
360
363
  # Fetch summaries, asserting on the RAW rows before collapsing into a hash:
361
364
  # a store returning one row per append (every historical fold version)
362
365
  # would otherwise pass — and then surface duplicate sessions from
363
- # list_sessions_from_store.
366
+ # list_sessions(session_store:).
364
367
  def summaries_by_id(store, project, expected_ids, message)
365
368
  rows = Array(store.list_session_summaries(project))
366
369
  assert_eq(rows.map { |s| s['session_id'] }.sort, expected_ids.sort, message)
@@ -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
@@ -56,7 +58,9 @@ module ClaudeAgentSDK
56
58
  MIRROR_APPEND_BACKOFF_S = [0.2, 0.8].freeze
57
59
 
58
60
  # @param store [SessionStore] the adapter to mirror into
59
- # @param projects_dir [String] base dir for file_path -> SessionKey mapping
61
+ # @param projects_dir [String, nil] base dir for file_path -> SessionKey
62
+ # mapping; nil when it could not be resolved (every frame is then
63
+ # dropped and reported via +on_error+)
60
64
  # @param on_error [#call] called as on_error.call(key, message) after a batch
61
65
  # exhausts retries; must not raise
62
66
  # @param callback_wrapper [#call, nil] the session's
@@ -255,6 +259,20 @@ module ClaudeAgentSDK
255
259
  by_path.each do |file_path, entries|
256
260
  next if entries.empty? # avoid phantom keys in adapters that touch storage on append([])
257
261
 
262
+ if @projects_dir.nil?
263
+ # No CLAUDE_CONFIG_DIR and no usable home (SessionStores.projects_dir,
264
+ # #120): the frame cannot be mapped to a SessionKey. Unlike a path
265
+ # outside a KNOWN projects dir, this is a host misconfiguration that
266
+ # loses the whole mirror, so it is surfaced as a MirrorErrorMessage.
267
+ @dropped_batches += 1
268
+ message = "cannot mirror #{file_path}: the Claude config directory is unknown (CLAUDE_CONFIG_DIR is " \
269
+ 'unset and the home directory could not be resolved); set CLAUDE_CONFIG_DIR in the ' \
270
+ 'environment or options.env'
271
+ errors << [nil, message]
272
+ warn "Claude SDK: [SessionStore] #{message}"
273
+ next
274
+ end
275
+
258
276
  key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
259
277
  if key.nil?
260
278
  @dropped_batches += 1