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