claude-agent-sdk 1.1.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +90 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -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 +228 -77
  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 +227 -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/option_values.rb +186 -4
  37. data/lib/claude_agent_sdk/types/options.rb +35 -5
  38. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +94 -46
  41. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  42. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  43. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  44. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  45. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  46. metadata +6 -4
@@ -29,10 +29,28 @@ module ClaudeAgentSDK
29
29
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
30
30
  # @api private
31
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'
32
44
  # @api private
33
45
  VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
34
46
  # @api private
35
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:'
36
54
  # After stdout EOF the child has closed (or lost) its last stdout handle,
37
55
  # so it is normally already exiting; a CLI still running this long
38
56
  # afterwards is wedged and gets the same TERM -> KILL ladder as #close.
@@ -126,7 +144,11 @@ module ClaudeAgentSDK
126
144
  super() # Transport defines no state today; keep the chain intact if it ever does
127
145
  # Support both new single-arg form and legacy two-arg form
128
146
  @options = options.nil? ? options_or_prompt : options
129
- @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)
130
152
  @cwd = @options.cwd
131
153
  @process = nil
132
154
  @stdin = nil
@@ -138,6 +160,7 @@ module ClaudeAgentSDK
138
160
  @stderr_task = nil
139
161
  @recent_stderr = []
140
162
  @recent_stderr_mutex = Mutex.new
163
+ @sandbox_warning_emitted = false
141
164
  # Serializes stdin access across the reactor fiber (transport writes
142
165
  # from inside Async) and user-callback threads spawned via FiberBoundary
143
166
  # (tool handlers / hooks calling Client#query). Without this lock,
@@ -158,15 +181,23 @@ module ClaudeAgentSDK
158
181
  end
159
182
 
160
183
  # Probe order (first hit wins):
161
- # 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.
162
188
  # 2. A project-local vendored binary (CLIInstaller). Deliberately ahead
163
189
  # of PATH: the point of a pinned, vendored CLI is that it beats
164
190
  # whatever version happens to be installed globally on the host.
165
- # 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.
166
195
  # 4. Well-known install locations.
167
196
  #
197
+ # Every hit is returned as an absolute path — see #settle_cli_path.
198
+ #
168
199
  # @api private
169
- 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)
170
201
  env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
171
202
  unless env_path.empty?
172
203
  # Absolutize against the CURRENT working directory, which is where the
@@ -189,15 +220,12 @@ module ClaudeAgentSDK
189
220
  end
190
221
  return vendored if vendored
191
222
 
192
- # Try which command first (using Open3 for thread safety)
193
- cli = nil
194
- begin
195
- stdout, _status = Open3.capture2('which', 'claude')
196
- cli = stdout.strip
197
- rescue StandardError
198
- # which command failed, try common locations
199
- end
200
- 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
201
229
 
202
230
  # Try common locations. The home-relative ones are skipped when no
203
231
  # usable home exists (see #home_dir), so a HOME-less container still
@@ -274,13 +302,28 @@ module ClaudeAgentSDK
274
302
  def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
275
303
  return if @process
276
304
 
277
- check_claude_version
278
-
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)
279
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
280
321
 
281
322
  # Build environment
282
- # Convert symbol keys to strings for spawn compatibility
283
- 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)
284
327
  # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
285
328
  # launches Claude Code from within an existing Claude Code terminal.
286
329
  # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
@@ -310,9 +353,19 @@ module ClaudeAgentSDK
310
353
  process_env['PWD'] = @cwd.to_s if @cwd
311
354
 
312
355
  # Determine stderr handling
313
- 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')
314
358
 
315
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
+
316
369
  # Start process using Open3
317
370
  # :uid mirrors Python's anyio.open_process(user=...): String username
318
371
  # or Integer uid (Unix; requires privileges — typically root). The
@@ -389,20 +442,27 @@ module ClaudeAgentSDK
389
442
  next if line_str.empty?
390
443
 
391
444
  record_bounded_stderr(line_str)
445
+ warn_if_sandbox_unavailable(line_str)
392
446
 
393
447
  # Per-line isolation: a callback that raises (e.g. user's logger
394
448
  # transiently failing) must not poison the rest of the stderr stream.
395
449
  # Without this, the first exception terminates the each_line loop and
396
450
  # the SDK silently stops capturing stderr for the lifetime of the
397
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.
398
457
  begin
399
458
  @options.stderr&.call(line_str)
400
- rescue StandardError
459
+ rescue StandardError, ScriptError, SystemStackError
401
460
  # Drop the callback error; the line is already in the recent-stderr
402
461
  # ring buffer, which is what ProcessError surfaces on non-zero exit.
403
462
  end
404
463
 
405
- # 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.
406
466
  begin
407
467
  if @options.debug_stderr
408
468
  if @options.debug_stderr.respond_to?(:puts)
@@ -411,7 +471,7 @@ module ClaudeAgentSDK
411
471
  File.open(@options.debug_stderr, 'a') { |f| f.puts(line_str) }
412
472
  end
413
473
  end
414
- rescue StandardError
474
+ rescue StandardError, ScriptError, SystemStackError
415
475
  # Drop debug_stderr write errors so they never interrupt the loop.
416
476
  end
417
477
  end
@@ -429,6 +489,7 @@ module ClaudeAgentSDK
429
489
  next if line_str.empty?
430
490
 
431
491
  record_bounded_stderr(line_str)
492
+ warn_if_sandbox_unavailable(line_str)
432
493
  end
433
494
  end
434
495
 
@@ -641,17 +702,18 @@ module ClaudeAgentSDK
641
702
  # Wait for the spawned process to exit, up to +timeout_seconds+. Polls
642
703
  # process.alive? rather than using stdlib Timeout.timeout, which raises
643
704
  # across threads via Thread#raise and corrupts Async fiber-scheduler state
644
- # (close is always called inside an Async task). Yields to the current
645
- # 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`.)
646
709
  #
647
710
  # @api private
648
711
  def wait_process_with_timeout(timeout_seconds, process = @process)
649
712
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
650
- task = defined?(Async::Task) ? Async::Task.current? : nil
651
713
  while process.alive?
652
714
  raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
653
715
 
654
- task ? task.sleep(0.05) : sleep(0.05)
716
+ sleep(0.05)
655
717
  end
656
718
  process.value
657
719
  end
@@ -833,7 +895,7 @@ module ClaudeAgentSDK
833
895
  # reached query()/receive_response. Past the grace period escalate
834
896
  # like #close (TERM, then KILL) and report it as an error below: a
835
897
  # child that outlives its stdout is wedged, whatever its exit code.
836
- # The poll parks only this task (task.sleep) on a reactor.
898
+ # The poll parks only this fiber (Kernel#sleep) on a reactor.
837
899
  if process && !process_exited_within?(process, EOF_EXIT_GRACE_SECONDS)
838
900
  forced_exit = true
839
901
  begin
@@ -920,6 +982,8 @@ module ClaudeAgentSDK
920
982
  # including '0'/'false'/' '; unset or empty string runs the check.
921
983
  skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil)
922
984
  return if skip && !skip.empty?
985
+ # Nothing to probe: #connect reports the unsettled path right after.
986
+ return unless cli_path_settled?
923
987
 
924
988
  begin
925
989
  output = capture_cli_version_output
@@ -960,6 +1024,102 @@ module ClaudeAgentSDK
960
1024
 
961
1025
  private
962
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
+
963
1123
  # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same
964
1124
  # injection-safety as capture3. Raises Timeout::Error past
965
1125
  # VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's
@@ -972,15 +1132,14 @@ module ClaudeAgentSDK
972
1132
  # also bounds CLI exit. ensure always reaps the probe (mirrors Python's
973
1133
  # finally: terminate(); wait()).
974
1134
  def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping
975
- stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
1135
+ stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path, '-v')
976
1136
  stdin.close
977
1137
  drainer = Thread.new { [stdout.read, stderr.read] }
978
1138
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS
979
- task = defined?(Async::Task) ? Async::Task.current? : nil
980
1139
  until drainer.join(0)
981
1140
  raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
982
1141
 
983
- task ? task.sleep(0.05) : sleep(0.05)
1142
+ sleep(0.05)
984
1143
  end
985
1144
  out, err = drainer.value
986
1145
  (out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip
@@ -1106,6 +1265,45 @@ module ClaudeAgentSDK
1106
1265
  end
1107
1266
  end
1108
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
+
1109
1307
  # The (parent's) home directory for the well-known install probes, or nil
1110
1308
  # when none is usable — see Sessions.home_dir, the one definition.
1111
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)