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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +110 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +40 -11
- data/docs/configuration.md +206 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +547 -132
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/messages.rb +7 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +104 -17
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +111 -53
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +20 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- 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
|
-
|
|
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 —
|
|
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. `
|
|
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/
|
|
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
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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).
|
|
627
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
#
|
|
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
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
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
|
|
154
|
-
#
|
|
155
|
-
#
|
|
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
|
|
172
|
-
# tasks <= 1 and detached batches <= 1
|
|
173
|
-
#
|
|
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)
|
|
179
|
-
#
|
|
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
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
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
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
355
|
-
task ? task.sleep(seconds) : sleep(seconds)
|
|
363
|
+
sleep(seconds)
|
|
356
364
|
end
|
|
357
365
|
|
|
358
366
|
def deep_stringify(obj)
|