claude-agent-sdk 1.0.0 → 1.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +110 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +40 -11
  7. data/docs/configuration.md +206 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +547 -132
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  37. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  38. data/lib/claude_agent_sdk/types/options.rb +104 -17
  39. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  40. data/lib/claude_agent_sdk/version.rb +1 -1
  41. data/lib/claude_agent_sdk.rb +111 -53
  42. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  43. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  44. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  45. data/sig/claude_agent_sdk/types/options.rbs +20 -7
  46. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  47. metadata +6 -4
@@ -16,14 +16,41 @@ module ClaudeAgentSDK
16
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
17
  # @api private
18
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # First Claude Code version that honors `client_composed` on user
20
+ # messages, which ClaudeAgentOptions#verbatim_prompts relies on.
21
+ # @api private
22
+ VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION = '2.1.248'
23
+ # Asks the CLI for session_state_changed frames marked sdk_host_only,
24
+ # which Query reads to tell when the run is over and keeps out of the
25
+ # caller's stream. CLIs that predate it send no frames.
26
+ # @api private
27
+ SDK_READS_SESSION_STATE_ENV_VAR = 'CLAUDE_CODE_SDK_READS_SESSION_STATE'
19
28
  # @api private
20
29
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
30
  # @api private
22
31
  CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
32
+ # What Ruby's own spawn searches for a bare command name when PATH is
33
+ # unset (dln_find_exe_r). #executable_on_path uses it for the same case,
34
+ # so a bare `cli_path` keeps resolving where it did.
35
+ #
36
+ # @api private
37
+ DEFAULT_EXEC_SEARCH_PATH = '/usr/local/bin:/usr/ucb:/usr/bin:/bin:.'
38
+ # What discovery searches when the process has no PATH (a service, a
39
+ # minimal container): the same system directories, without the `.` —
40
+ # discovery never picks a `claude` out of the working directory.
41
+ #
42
+ # @api private
43
+ DEFAULT_DISCOVERY_SEARCH_PATH = '/usr/local/bin:/usr/ucb:/usr/bin:/bin'
23
44
  # @api private
24
45
  VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
25
46
  # @api private
26
47
  RECENT_STDERR_LINES_LIMIT = 20
48
+ # How the CLI's stderr says that a requested sandbox could not start and
49
+ # that the session carries on without one — see
50
+ # #warn_if_sandbox_unavailable.
51
+ #
52
+ # @api private
53
+ SANDBOX_DISABLED_MARKER = 'Sandbox disabled:'
27
54
  # After stdout EOF the child has closed (or lost) its last stdout handle,
28
55
  # so it is normally already exiting; a CLI still running this long
29
56
  # afterwards is wedged and gets the same TERM -> KILL ladder as #close.
@@ -117,7 +144,11 @@ module ClaudeAgentSDK
117
144
  super() # Transport defines no state today; keep the chain intact if it ever does
118
145
  # Support both new single-arg form and legacy two-arg form
119
146
  @options = options.nil? ? options_or_prompt : options
120
- @cli_path = @options.cli_path || find_cli
147
+ # What the caller named (any falsy cli_path means discovery, as it
148
+ # always did), as a String: the option may be a Pathname. Settled
149
+ # here, and again by #connect.
150
+ @given_cli_path = (@options.cli_path || find_cli).to_s
151
+ @cli_path = settle_cli_path(@given_cli_path)
121
152
  @cwd = @options.cwd
122
153
  @process = nil
123
154
  @stdin = nil
@@ -129,6 +160,7 @@ module ClaudeAgentSDK
129
160
  @stderr_task = nil
130
161
  @recent_stderr = []
131
162
  @recent_stderr_mutex = Mutex.new
163
+ @sandbox_warning_emitted = false
132
164
  # Serializes stdin access across the reactor fiber (transport writes
133
165
  # from inside Async) and user-callback threads spawned via FiberBoundary
134
166
  # (tool handlers / hooks calling Client#query). Without this lock,
@@ -149,15 +181,23 @@ module ClaudeAgentSDK
149
181
  end
150
182
 
151
183
  # Probe order (first hit wins):
152
- # 1. CLAUDE_CLI_PATH — explicit operator override, no discovery at all.
184
+ # 1. CLAUDE_CLI_PATH — the operator's override, when it names an
185
+ # executable regular file. A value that does not (a missing file, a
186
+ # directory, a file that is not executable) is skipped without a
187
+ # warning, and discovery goes on with the steps below.
153
188
  # 2. A project-local vendored binary (CLIInstaller). Deliberately ahead
154
189
  # of PATH: the point of a pinned, vendored CLI is that it beats
155
190
  # whatever version happens to be installed globally on the host.
156
- # 3. `which claude`.
191
+ # 3. `claude` on this process's PATH, searched here
192
+ # (#executable_on_path). Not by running `which`: that program would
193
+ # itself be looked up on PATH, relative entries included, and its
194
+ # answer would have to be believed.
157
195
  # 4. Well-known install locations.
158
196
  #
197
+ # Every hit is returned as an absolute path — see #settle_cli_path.
198
+ #
159
199
  # @api private
160
- def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations)
200
+ def find_cli # rubocop:disable Metrics/MethodLength -- ordered discovery probes (env, vendored, PATH, known locations)
161
201
  env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
162
202
  unless env_path.empty?
163
203
  # Absolutize against the CURRENT working directory, which is where the
@@ -180,15 +220,12 @@ module ClaudeAgentSDK
180
220
  end
181
221
  return vendored if vendored
182
222
 
183
- # Try which command first (using Open3 for thread safety)
184
- cli = nil
185
- begin
186
- stdout, _status = Open3.capture2('which', 'claude')
187
- cli = stdout.strip
188
- rescue StandardError
189
- # which command failed, try common locations
190
- end
191
- return cli if cli && !cli.empty? && File.executable?(cli)
223
+ # The process's own PATH, the one `which claude` used to search here: a
224
+ # PATH set through options.env is the session's, and only a bare
225
+ # cli_path is searched on it (#spawn_search_path). With no PATH, the
226
+ # system directories, as Ruby's own lookup would search them.
227
+ on_path = executable_on_path('claude', ENV.fetch('PATH', DEFAULT_DISCOVERY_SEARCH_PATH))
228
+ return on_path if on_path
192
229
 
193
230
  # Try common locations. The home-relative ones are skipped when no
194
231
  # usable home exists (see #home_dir), so a HOME-less container still
@@ -265,13 +302,28 @@ module ClaudeAgentSDK
265
302
  def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
266
303
  return if @process
267
304
 
268
- check_claude_version
269
-
305
+ # Settled again where spawn used to look the CLI up: a CLI installed
306
+ # after this transport was built is found, and a relative path
307
+ # follows the process cwd of this connect, where the probe runs.
308
+ @cli_path = settle_cli_path(@given_cli_path)
270
309
  cmd = build_command
310
+ # A subclass whose #build_command runs the CLI through another program
311
+ # (docker exec, ssh) names a file on the far side. A path settled
312
+ # against this host — anchored to its cwd, or found on its PATH — would
313
+ # not exist there, so that argv gets cli_path as the caller gave it,
314
+ # and the probe below runs only for an absolute one (as for any path
315
+ # that is not settled).
316
+ unless cmd.first == @cli_path
317
+ @cli_path = @given_cli_path
318
+ cmd = build_command
319
+ end
320
+ check_claude_version
271
321
 
272
322
  # Build environment
273
- # Convert symbol keys to strings for spawn compatibility
274
- custom_env = @options.env.transform_keys(&:to_s)
323
+ # Convert symbol keys to strings for spawn compatibility. `|| {}`: the
324
+ # constructor defaults env (and extra_args, below) to {}, but both are
325
+ # nil again after `options.dup_with(env: nil)` or `options.env = nil`.
326
+ custom_env = (@options.env || {}).transform_keys(&:to_s)
275
327
  # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
276
328
  # launches Claude Code from within an existing Claude Code terminal.
277
329
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
@@ -288,13 +340,32 @@ module ClaudeAgentSDK
288
340
  # under the caller's distributed trace (Python SDK #821 parity). No-op
289
341
  # when opentelemetry is not loaded or there is no active span.
290
342
  inject_otel_trace_context(process_env, custom_env)
343
+ # Query waits for the CLI's session_state_changed "idle" before closing
344
+ # stdin on a run that serves control requests (Python #1279). Ask for
345
+ # the frames it drops (sdk_host_only) unless the caller named the
346
+ # variable, in any case, in options.env (a nil value unsets it) or the
347
+ # inherited environment. CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS stays the
348
+ # caller's own opt-in to seeing the frames; the SDK never sets it.
349
+ unless process_env.keys.any? { |key| key.casecmp?(SDK_READS_SESSION_STATE_ENV_VAR) }
350
+ process_env[SDK_READS_SESSION_STATE_ENV_VAR] = '1'
351
+ end
291
352
  process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
292
353
  process_env['PWD'] = @cwd.to_s if @cwd
293
354
 
294
355
  # Determine stderr handling
295
- should_pipe_stderr = @options.stderr || @options.debug_stderr || @options.extra_args.key?('debug-to-stderr')
356
+ should_pipe_stderr = @options.stderr || @options.debug_stderr ||
357
+ (@options.extra_args || {}).key?('debug-to-stderr')
296
358
 
297
359
  begin
360
+ # A path #settle_cli_path could not settle is never handed to spawn:
361
+ # spawn searches PATH on its own, in this process's cwd, and then
362
+ # executes a relative hit inside options.cwd. Reported like any
363
+ # missing CLI, by the Errno::ENOENT branch below. Only when that
364
+ # path is the program about to be spawned: a subclass whose
365
+ # #build_command wraps the CLI in another program (docker exec, ssh)
366
+ # names a file on the far side, and its argv is spawned as built.
367
+ raise Errno::ENOENT, @cli_path if cmd.first == @cli_path && !cli_path_settled?
368
+
298
369
  # Start process using Open3
299
370
  # :uid mirrors Python's anyio.open_process(user=...): String username
300
371
  # or Integer uid (Unix; requires privileges — typically root). The
@@ -371,20 +442,27 @@ module ClaudeAgentSDK
371
442
  next if line_str.empty?
372
443
 
373
444
  record_bounded_stderr(line_str)
445
+ warn_if_sandbox_unavailable(line_str)
374
446
 
375
447
  # Per-line isolation: a callback that raises (e.g. user's logger
376
448
  # transiently failing) must not poison the rest of the stderr stream.
377
449
  # Without this, the first exception terminates the each_line loop and
378
450
  # the SDK silently stops capturing stderr for the lifetime of the
379
451
  # process. Matches Python SDK v0.2.82 (PR #932).
452
+ # ScriptError and SystemStackError as well: a callback raising
453
+ # NotImplementedError or LoadError (both ScriptErrors), or recursing
454
+ # too deep, used to end this thread, and with nothing reading the
455
+ # pipe a CLI that wrote one more pipe buffer of stderr (64 KiB)
456
+ # blocked in write(2) for good.
380
457
  begin
381
458
  @options.stderr&.call(line_str)
382
- rescue StandardError
459
+ rescue StandardError, ScriptError, SystemStackError
383
460
  # Drop the callback error; the line is already in the recent-stderr
384
461
  # ring buffer, which is what ProcessError surfaces on non-zero exit.
385
462
  end
386
463
 
387
- # Write to debug_stderr file/IO if provided, also isolated.
464
+ # Write to debug_stderr file/IO if provided, also isolated — from the
465
+ # same exceptions as the callback, for the same reason.
388
466
  begin
389
467
  if @options.debug_stderr
390
468
  if @options.debug_stderr.respond_to?(:puts)
@@ -393,7 +471,7 @@ module ClaudeAgentSDK
393
471
  File.open(@options.debug_stderr, 'a') { |f| f.puts(line_str) }
394
472
  end
395
473
  end
396
- rescue StandardError
474
+ rescue StandardError, ScriptError, SystemStackError
397
475
  # Drop debug_stderr write errors so they never interrupt the loop.
398
476
  end
399
477
  end
@@ -411,6 +489,7 @@ module ClaudeAgentSDK
411
489
  next if line_str.empty?
412
490
 
413
491
  record_bounded_stderr(line_str)
492
+ warn_if_sandbox_unavailable(line_str)
414
493
  end
415
494
  end
416
495
 
@@ -623,17 +702,18 @@ module ClaudeAgentSDK
623
702
  # Wait for the spawned process to exit, up to +timeout_seconds+. Polls
624
703
  # process.alive? rather than using stdlib Timeout.timeout, which raises
625
704
  # across threads via Thread#raise and corrupts Async fiber-scheduler state
626
- # (close is always called inside an Async task). Yields to the current
627
- # Async task when one is active so the reactor keeps running.
705
+ # (close is always called inside an Async task). Kernel#sleep is
706
+ # scheduler-aware: on a reactor it parks only the calling fiber, so the
707
+ # reactor keeps running. (Async::Task#sleep did the same but is
708
+ # deprecated, and warns on every call under `ruby -w`.)
628
709
  #
629
710
  # @api private
630
711
  def wait_process_with_timeout(timeout_seconds, process = @process)
631
712
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
632
- task = defined?(Async::Task) ? Async::Task.current? : nil
633
713
  while process.alive?
634
714
  raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
635
715
 
636
- task ? task.sleep(0.05) : sleep(0.05)
716
+ sleep(0.05)
637
717
  end
638
718
  process.value
639
719
  end
@@ -815,7 +895,7 @@ module ClaudeAgentSDK
815
895
  # reached query()/receive_response. Past the grace period escalate
816
896
  # like #close (TERM, then KILL) and report it as an error below: a
817
897
  # child that outlives its stdout is wedged, whatever its exit code.
818
- # The poll parks only this task (task.sleep) on a reactor.
898
+ # The poll parks only this fiber (Kernel#sleep) on a reactor.
819
899
  if process && !process_exited_within?(process, EOF_EXIT_GRACE_SECONDS)
820
900
  forced_exit = true
821
901
  begin
@@ -902,6 +982,8 @@ module ClaudeAgentSDK
902
982
  # including '0'/'false'/' '; unset or empty string runs the check.
903
983
  skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil)
904
984
  return if skip && !skip.empty?
985
+ # Nothing to probe: #connect reports the unsettled path right after.
986
+ return unless cli_path_settled?
905
987
 
906
988
  begin
907
989
  output = capture_cli_version_output
@@ -922,6 +1004,13 @@ module ClaudeAgentSDK
922
1004
  'Some features may not work correctly.'
923
1005
  warn warning
924
1006
  end
1007
+
1008
+ verbatim_min_parts = VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
1009
+ if @options.verbatim_prompts? && (version_parts <=> verbatim_min_parts).negative?
1010
+ warn "Warning: verbatim_prompts is enabled, but Claude Code version #{version} at #{@cli_path} " \
1011
+ 'ignores it: prompts will still have @path mentions expanded and slash commands dispatched. ' \
1012
+ "Claude Code #{VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION} or later is required."
1013
+ end
925
1014
  end
926
1015
  rescue StandardError
927
1016
  # Ignore version check errors — including Timeout::Error from the
@@ -935,6 +1024,102 @@ module ClaudeAgentSDK
935
1024
 
936
1025
  private
937
1026
 
1027
+ # Settle the CLI on ONE absolute path, used for the version probe and for
1028
+ # the spawn alike. The probe runs in this process's working directory,
1029
+ # the CLI is spawned with `chdir: options.cwd`: a path still relative at
1030
+ # the spawn names a different file there — whatever sits at that path
1031
+ # inside the directory the agent was pointed at — while the probe vouched
1032
+ # for the one here.
1033
+ #
1034
+ # - a path with a separator is anchored to the process cwd (an absolute
1035
+ # one is kept as given); a leading `~` becomes a home directory, as
1036
+ # for CLAUDE_CLI_PATH;
1037
+ # - a bare name is searched on PATH here (#executable_on_path) rather
1038
+ # than left to spawn.
1039
+ #
1040
+ # Nothing is normalized: `link/..` must resolve through the filesystem,
1041
+ # as it did when the path went to spawn as given. (File.expand_path
1042
+ # collapses it lexically, which names another file when `link` is a
1043
+ # symlink.)
1044
+ #
1045
+ # Never raises. A path that cannot be settled (a bare name not on PATH,
1046
+ # `~user` naming nobody, a relative path when the cwd is gone, a NUL
1047
+ # byte) is returned as given, and #connect reports it as
1048
+ # CLINotFoundError.
1049
+ def settle_cli_path(path)
1050
+ return executable_on_path(path, spawn_search_path) || path unless path.include?(File::SEPARATOR)
1051
+
1052
+ path.start_with?('~') ? expand_leading_tilde(path) : anchor_to_cwd(path)
1053
+ rescue ArgumentError, EncodingError, SystemCallError
1054
+ path
1055
+ end
1056
+
1057
+ # An absolute path as given, a relative one joined to the process cwd.
1058
+ def anchor_to_cwd(path)
1059
+ File.absolute_path?(path) ? path : File.join(Dir.pwd, path)
1060
+ end
1061
+
1062
+ # `~/x` or `~user/x`: the first component becomes that home directory,
1063
+ # the rest is kept as written.
1064
+ def expand_leading_tilde(path)
1065
+ head, rest = path.split(File::SEPARATOR, 2)
1066
+ File.join(File.expand_path(head), rest)
1067
+ end
1068
+
1069
+ # The first executable regular file called +name+ on +search_path+, as an
1070
+ # absolute path, or nil. This is the lookup spawn did for a bare command
1071
+ # name (and `which` for discovery), done here so that nothing is run to
1072
+ # find the CLI and nothing relative comes back: a relative entry — `bin`,
1073
+ # `.`, the empty entry — is anchored to the process cwd, where spawn
1074
+ # found the file here and then executed that relative path inside
1075
+ # options.cwd.
1076
+ def executable_on_path(name, search_path)
1077
+ return nil if name.empty? || search_path.nil?
1078
+
1079
+ search_path_entries(search_path).each do |entry|
1080
+ candidate = File.join(path_entry_dir(entry), name)
1081
+ return candidate if File.file?(candidate) && File.executable?(candidate)
1082
+ rescue ArgumentError, EncodingError, SystemCallError
1083
+ next # a NUL byte, mixed encodings, or a relative entry when the cwd is gone
1084
+ end
1085
+ nil
1086
+ end
1087
+
1088
+ # The PATH spawn searched for a bare cli_path: the one the child is given
1089
+ # through options.env when it sets one, else this process's, else Ruby's
1090
+ # built-in default. (Discovery searches the process's PATH — #find_cli.)
1091
+ def spawn_search_path
1092
+ env = @options.env
1093
+ from_options = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash)
1094
+ from_options || ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH)
1095
+ end
1096
+
1097
+ # A PATH value as its entries.
1098
+ def search_path_entries(search_path)
1099
+ search_path = search_path.to_s
1100
+ # spawn searched bytes; a PATH that is invalid in its encoding cannot
1101
+ # be split as text.
1102
+ search_path = search_path.b unless search_path.valid_encoding?
1103
+ # -1 keeps a trailing empty entry; PATH="" is one empty entry.
1104
+ entries = search_path.split(File::PATH_SEPARATOR, -1)
1105
+ entries.empty? ? [''] : entries
1106
+ end
1107
+
1108
+ # As in spawn's lookup, `~` and `~/x` entries start at the home directory
1109
+ # (a `~user` entry is taken literally, as there).
1110
+ def path_entry_dir(entry)
1111
+ return File.join(File.expand_path('~'), entry[1..]) if entry == '~' || entry.start_with?('~/')
1112
+
1113
+ anchor_to_cwd(entry)
1114
+ end
1115
+
1116
+ # False when #settle_cli_path had to keep a path as given.
1117
+ def cli_path_settled?
1118
+ File.absolute_path?(@cli_path)
1119
+ rescue StandardError
1120
+ false # nil from a subclass's #find_cli, a NUL byte, an incompatible encoding
1121
+ end
1122
+
938
1123
  # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same
939
1124
  # injection-safety as capture3. Raises Timeout::Error past
940
1125
  # VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's
@@ -947,15 +1132,14 @@ module ClaudeAgentSDK
947
1132
  # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
948
1133
  # finally: terminate(); wait()).
949
1134
  def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping
950
- stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
1135
+ stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path, '-v')
951
1136
  stdin.close
952
1137
  drainer = Thread.new { [stdout.read, stderr.read] }
953
1138
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS
954
- task = defined?(Async::Task) ? Async::Task.current? : nil
955
1139
  until drainer.join(0)
956
1140
  raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
957
1141
 
958
- task ? task.sleep(0.05) : sleep(0.05)
1142
+ sleep(0.05)
959
1143
  end
960
1144
  out, err = drainer.value
961
1145
  (out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip
@@ -1081,6 +1265,45 @@ module ClaudeAgentSDK
1081
1265
  end
1082
1266
  end
1083
1267
 
1268
+ # When the sandbox was requested but cannot start (and
1269
+ # sandbox.failIfUnavailable is not set), the CLI writes
1270
+ # "⚠ Sandbox disabled: <reason>" to stderr and runs the session's
1271
+ # commands unsandboxed. A host only sees stderr through a `stderr:`
1272
+ # callback, so repeat that line as a Ruby warning: once per transport,
1273
+ # and only when this session's own options asked for the sandbox.
1274
+ #
1275
+ # Called for every stderr line, on the drain thread. Best-effort like
1276
+ # OptionWarnings#emit: a closed or broken $stderr — or a custom
1277
+ # Warning.warn / $stderr sink raising NotImplementedError, LoadError or
1278
+ # SystemStackError, the exceptions the stderr callback is contained from
1279
+ # — must not raise out of here and end the drain (the CLI would stall on
1280
+ # a full stderr pipe).
1281
+ def warn_if_sandbox_unavailable(line)
1282
+ return if @sandbox_warning_emitted || !line.include?(SANDBOX_DISABLED_MARKER)
1283
+ return unless sandbox_requested?
1284
+
1285
+ @sandbox_warning_emitted = true
1286
+ begin
1287
+ warn '[claude-agent-sdk] The sandbox this session requested is not active: the CLI is running ' \
1288
+ "commands WITHOUT sandboxing. It reported: \"#{line.strip}\". To make this an error instead, " \
1289
+ 'set fail_if_unavailable: true on SandboxSettings (failIfUnavailable: true in a Hash).'
1290
+ rescue StandardError, ScriptError, SystemStackError
1291
+ nil
1292
+ end
1293
+ end
1294
+
1295
+ # True when ClaudeAgentOptions#sandbox enables the sandbox: `true`, a
1296
+ # SandboxSettings with `enabled` true, or a Hash with an `enabled` /
1297
+ # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim).
1298
+ def sandbox_requested?
1299
+ sandbox = @options.sandbox
1300
+ case sandbox
1301
+ when SandboxSettings then sandbox.enabled == true
1302
+ when Hash then sandbox[:enabled] == true || sandbox['enabled'] == true
1303
+ else sandbox == true
1304
+ end
1305
+ end
1306
+
1084
1307
  # The (parent's) home directory for the well-known install probes, or nil
1085
1308
  # when none is usable — see Sessions.home_dir, the one definition.
1086
1309
  def home_dir
@@ -42,7 +42,7 @@ module ClaudeAgentSDK
42
42
  # returns a fresh SessionStore (or duck-typed adapter).
43
43
  # @param skip_optional [Array<String>] optional method names to skip.
44
44
  # Contracts for an optional method are also skipped automatically when the
45
- # store does not override it.
45
+ # store does not override it, or raises NotImplementedError from it.
46
46
  # @param check_uuid_dedupe [Boolean] additionally assert the ADVISORY
47
47
  # uuid-dedupe recommendation: re-appending a batch that overlaps a prior
48
48
  # write (exactly what the mirror batcher's retry can produce) must not
@@ -58,11 +58,22 @@ module ClaudeAgentSDK
58
58
 
59
59
  fresh = -> { make_store.call }
60
60
 
61
+ # Each optional method is called once on an empty store before its
62
+ # contracts are selected: an adapter that inherits the SessionStore stub
63
+ # behind a delegating wrapper, or declines the method at run time,
64
+ # raises NotImplementedError — "not implemented", so its contracts are
65
+ # skipped as for a method the adapter does not define. The calls are
66
+ # ones the contracts make anyway (unknown project / never-written key).
61
67
  probe = fresh.call
62
- has_list_sessions = optional?(probe, 'list_sessions', skip_optional)
63
- has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional)
64
- has_delete = optional?(probe, 'delete', skip_optional)
65
- has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
68
+ unwritten = { 'project_key' => 'proj', 'session_id' => 'never-written' }
69
+ has_list_sessions = optional?(probe, 'list_sessions', skip_optional) do
70
+ probe.list_sessions('never-appended-project')
71
+ end
72
+ has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional) do
73
+ probe.list_session_summaries('never-appended-project')
74
+ end
75
+ has_delete = optional?(probe, 'delete', skip_optional) { probe.delete(unwritten) }
76
+ has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional) { probe.list_subkeys(unwritten) }
66
77
 
67
78
  check_callback_scheduling_declaration(fresh)
68
79
  check_append_and_load(fresh, has_list_sessions)
@@ -370,10 +381,10 @@ module ClaudeAgentSDK
370
381
  rows.to_h { |s| [s['session_id'], s] }
371
382
  end
372
383
 
373
- def optional?(store, method, skip_optional)
384
+ def optional?(store, method, skip_optional, &)
374
385
  return false if skip_optional.include?(method)
375
386
 
376
- SessionStore.implements?(store, method.to_sym)
387
+ SessionStores.optional_call(store, method.to_sym, &).first
377
388
  end
378
389
 
379
390
  def assert(condition, message)
@@ -31,8 +31,8 @@ module ClaudeAgentSDK
31
31
  # (which surfaces them as a MirrorErrorMessage). Adapters should dedupe by
32
32
  # entry["uuid"] when present, since a retried batch may overlap a prior write.
33
33
  #
34
- # The semaphore serializes appends (FIFO, and every drain detaches its batch
35
- # immediately before queueing on it, so append order matches enqueue order),
34
+ # The semaphore serializes appends (FIFO, and every drain detaches the whole
35
+ # buffer once it holds the lock, so append order matches enqueue order),
36
36
  # but a #send that exceeds send_timeout is abandoned (its worker thread keeps
37
37
  # running) and the next drain proceeds, so two #append calls for the SAME
38
38
  # key can briefly overlap. SessionStore#append must be thread-safe per key
@@ -107,14 +107,16 @@ module ClaudeAgentSDK
107
107
  #
108
108
  # Frames still buffered once #close has started count too. Query#close
109
109
  # stops the read task (the background drainer's parent) right after
110
- # #close returns, so a frame the read loop enqueued during the close
111
- # window — or any below-threshold frame after it — is never appended.
112
- # The old per-frame drain detached it into a parked task whose
113
- # cancellation counted it; with coalescing it stays in @pending, so it is
114
- # accounted here. Evaluated lazily: frames the drainer still manages to
115
- # deliver before the check are not drops, and nothing past #close has to
116
- # run for a stranded frame to be counted. (A plain ivar/Array#empty? read
117
- # is safe cross-thread under the GVL, like @dropped_batches.)
110
+ # #close returns, so a frame the read loop enqueued after #close detached
111
+ # — during its final append, or any below-threshold frame after it — is
112
+ # never appended. (One that arrives while #close is still waiting for the
113
+ # lock goes out with #close's own batch.) The old per-frame drain detached
114
+ # such a frame into a parked task whose cancellation counted it; with
115
+ # coalescing it stays in @pending, so it is accounted here. Evaluated
116
+ # lazily: frames the drainer still manages to deliver before the check
117
+ # are not drops, and nothing past #close has to run for a stranded frame
118
+ # to be counted. (A plain ivar/Array#empty? read is safe cross-thread
119
+ # under the GVL, like @dropped_batches.)
118
120
  def batches_dropped?
119
121
  @dropped_batches.positive? || (@closed && !@pending.empty?)
120
122
  end
@@ -150,9 +152,10 @@ module ClaudeAgentSDK
150
152
  drain
151
153
  end
152
154
 
153
- # Final flush before teardown. Never raises. Bounded: it waits behind at
154
- # most the in-flight append, and frames arriving after it detached are
155
- # not chased (they count as dropped unless delivered; #batches_dropped?).
155
+ # Final flush before teardown. Never raises. Bounded: it waits its turn on
156
+ # the lock, takes whatever is pending at that moment, and does not chase
157
+ # frames that arrive after that (they count as dropped unless delivered;
158
+ # #batches_dropped?).
156
159
  def close
157
160
  @closed = true
158
161
  flush
@@ -168,16 +171,16 @@ module ClaudeAgentSDK
168
171
 
169
172
  # Fire-and-forget on the reactor. The drainer loops until the buffer is
170
173
  # back under the thresholds, so while it is live every later frame is
171
- # simply buffered and coalesced into its next #drain — live background
172
- # tasks <= 1 and detached batches <= 1 + one per #flush / #close caller
173
- # parked on @lock, independent of frame rate and store latency.
174
+ # simply buffered and coalesced into the next #drain — live background
175
+ # tasks <= 1 and detached batches <= 1 (only the holder of @lock has
176
+ # one), independent of frame rate and store latency.
174
177
  #
175
178
  # No lost wakeup: the loop's final over_threshold? check and the ensure
176
179
  # clearing the flag run with no suspension point between them, so an
177
180
  # #enqueue that saw the flag set is always observed by that check.
178
- # Every drainer (this one, #flush, #close) keeps detach-then-acquire, so
179
- # detach order == @lock FIFO order and append ordering holds. #drain
180
- # never raises.
181
+ # Every drainer (this one, #flush, #close) detaches inside @lock and
182
+ # takes everything pending at that moment, so batches leave in @lock FIFO
183
+ # order and append ordering holds. #drain never raises.
181
184
  def schedule_background_drain(task)
182
185
  return if @background_drain_live
183
186
 
@@ -194,25 +197,29 @@ module ClaudeAgentSDK
194
197
  raise
195
198
  end
196
199
 
197
- # Detach the pending buffer, await any prior flush, then send. Detaching
198
- # before acquiring the lock lets #enqueue keep accumulating into a fresh
199
- # buffer while a prior flush is in flight. Never raises.
200
+ # Await any prior flush, detach the pending buffer, then send. The buffer
201
+ # is detached INSIDE the lock: a drain cancelled while it waits for the
202
+ # lock has taken nothing, so its frames stay in @pending for the next
203
+ # drain. (It used to detach first, and a #flush cancelled while queued
204
+ # behind an in-flight append lost its batch — upstream Python PR #1289.)
205
+ # #enqueue never takes the lock, so it keeps accumulating while a flush
206
+ # is in flight. Never raises.
200
207
  def drain
201
- items = @pending
202
- @pending = []
203
- @pending_entries = 0
204
- @pending_bytes = 0
205
-
206
208
  errors = []
207
- # Cancellation (Async::Stop — not a StandardError) delivered while
208
- # waiting on the lock or inside the append's thread join loses the
209
- # detached items without either rescue firing. Teardown would then read
210
- # batches_dropped? as false and delete a materialized resume dir holding
211
- # the only copy of these entries — count the batch as dropped unless the
212
- # flush path ran to completion (do_flush counts its own failures).
213
- accounted = items.empty?
209
+ # Cancellation (Async::Stop — not a StandardError) delivered inside the
210
+ # append's thread join loses the detached items without either rescue
211
+ # firing. Teardown would then read batches_dropped? as false and delete
212
+ # a materialized resume dir holding the only copy of these entries —
213
+ # count the batch as dropped unless the flush path ran to completion
214
+ # (do_flush counts its own failures). Until a batch is detached there
215
+ # is nothing to account for.
216
+ accounted = true
214
217
  begin
215
218
  @lock.acquire do
219
+ items = @pending
220
+ @pending = []
221
+ @pending_entries = 0
222
+ @pending_bytes = 0
216
223
  # Emptiness is checked INSIDE the lock (matching the Python batcher):
217
224
  # an empty #flush/#close still serializes behind any in-flight or
218
225
  # queued drain, so they are true barriers — at result-yield and at
@@ -220,6 +227,7 @@ module ClaudeAgentSDK
220
227
  # the read task while a detached batch is still being appended.
221
228
  next if items.empty?
222
229
 
230
+ accounted = false
223
231
  begin
224
232
  do_flush(items, errors)
225
233
  rescue StandardError => e
@@ -349,10 +357,10 @@ module ClaudeAgentSDK
349
357
  [:error, e]
350
358
  end
351
359
 
352
- # Sleep that yields the reactor when one is active, else a plain sleep.
360
+ # Kernel#sleep is scheduler-aware: it parks only this fiber when a reactor
361
+ # is active and blocks the thread otherwise.
353
362
  def sleep_backoff(seconds)
354
- task = Async::Task.current?
355
- task ? task.sleep(seconds) : sleep(seconds)
363
+ sleep(seconds)
356
364
  end
357
365
 
358
366
  def deep_stringify(obj)