claude-agent-sdk 0.36.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 +17 -0
- data/README.md +2 -2
- data/docs/client.md +26 -1
- data/docs/hooks-and-permissions.md +5 -3
- data/docs/mcp-servers.md +1 -2
- data/docs/sessions.md +81 -2
- data/docs/types.md +106 -4
- data/lib/claude_agent_sdk/cli_installer.rb +30 -3
- data/lib/claude_agent_sdk/command_builder.rb +109 -98
- data/lib/claude_agent_sdk/deprecation.rb +39 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
- 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 -0
- data/lib/claude_agent_sdk/query.rb +50 -43
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
- data/lib/claude_agent_sdk/session_mutations.rb +20 -8
- data/lib/claude_agent_sdk/session_resume.rb +20 -11
- data/lib/claude_agent_sdk/session_store.rb +7 -3
- data/lib/claude_agent_sdk/session_summary.rb +4 -2
- data/lib/claude_agent_sdk/sessions.rb +8 -6
- data/lib/claude_agent_sdk/streaming.rb +1 -1
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
- 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 +51 -17
- metadata +11 -1
|
@@ -56,8 +56,8 @@ module ClaudeAgentSDK
|
|
|
56
56
|
# mode) and a cancelled append may remain permanently half-applied in the
|
|
57
57
|
# store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
|
|
58
58
|
# the local transcript remains the source of truth; the
|
|
59
|
-
# dedupe-by-entry-uuid recommendation above stays advisory. The method is
|
|
60
|
-
# `respond_to?(:callback_scheduling)` (see
|
|
59
|
+
# dedupe-by-entry-uuid recommendation above stays advisory. The method is
|
|
60
|
+
# deliberately NOT defined here: the SDK probes `respond_to?(:callback_scheduling)` (see
|
|
61
61
|
# SessionStores.store_callback_scheduling), so pure duck-typed adapters
|
|
62
62
|
# stay minimal, and an app can opt a third-party fiber-native adapter in
|
|
63
63
|
# via a singleton method (`def store.callback_scheduling = :inline`).
|
|
@@ -290,6 +290,8 @@ module ClaudeAgentSDK
|
|
|
290
290
|
end
|
|
291
291
|
|
|
292
292
|
# Internal SessionStore support functions (path mapping, option validation).
|
|
293
|
+
#
|
|
294
|
+
# @api private
|
|
293
295
|
module SessionStores
|
|
294
296
|
STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
|
|
295
297
|
|
|
@@ -349,7 +351,9 @@ module ClaudeAgentSDK
|
|
|
349
351
|
second = parts[1]
|
|
350
352
|
|
|
351
353
|
# Main transcript: <project_key>/<session_id>.jsonl
|
|
352
|
-
|
|
354
|
+
if parts.length == 2 && second.end_with?('.jsonl')
|
|
355
|
+
return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') }
|
|
356
|
+
end
|
|
353
357
|
|
|
354
358
|
# Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
|
|
355
359
|
if parts.length >= 4
|
|
@@ -16,6 +16,8 @@ module ClaudeAgentSDK
|
|
|
16
16
|
# (string keys from JSON), and the summary's opaque +data+ dict is persisted
|
|
17
17
|
# verbatim by adapters — string keys survive a JSON round-trip (Postgres
|
|
18
18
|
# JSONB, Redis) losslessly, whereas symbol keys would not.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
19
21
|
module SessionSummary
|
|
20
22
|
# JSONL entry keys -> summary data keys for last-wins string fields. Each
|
|
21
23
|
# appended entry overwrites the previous value when present.
|
|
@@ -49,7 +51,7 @@ module ClaudeAgentSDK
|
|
|
49
51
|
# @param key [Hash] the SessionKey (string keys)
|
|
50
52
|
# @param entries [Array<Hash>] newly appended transcript entries
|
|
51
53
|
# @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
|
|
52
|
-
def fold_session_summary(prev, key, entries)
|
|
54
|
+
def fold_session_summary(prev, key, entries) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- incremental fold over every summary field
|
|
53
55
|
summary = if prev
|
|
54
56
|
{ 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
|
|
55
57
|
else
|
|
@@ -147,7 +149,7 @@ module ClaudeAgentSDK
|
|
|
147
149
|
# deliberately match the disk extractor (not Python's per-char replace) so
|
|
148
150
|
# the Ruby store path and disk path produce identical first_prompt values
|
|
149
151
|
# for the same transcript.
|
|
150
|
-
def fold_first_prompt(data, entry)
|
|
152
|
+
def fold_first_prompt(data, entry) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- must match the disk first-prompt extractor rule for rule
|
|
151
153
|
return if data['first_prompt_locked']
|
|
152
154
|
return unless entry['type'] == 'user'
|
|
153
155
|
return if entry['isMeta'] == true || entry['isCompactSummary'] == true
|
|
@@ -83,7 +83,9 @@ module ClaudeAgentSDK
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# Session browsing functions
|
|
86
|
-
|
|
86
|
+
#
|
|
87
|
+
# @api private
|
|
88
|
+
module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/reading functions share private helpers
|
|
87
89
|
LITE_READ_BUF_SIZE = 65_536
|
|
88
90
|
MAX_SANITIZED_LENGTH = 200
|
|
89
91
|
|
|
@@ -435,7 +437,7 @@ module ClaudeAgentSDK
|
|
|
435
437
|
end
|
|
436
438
|
|
|
437
439
|
# Extract the first meaningful user prompt from the head of a JSONL file
|
|
438
|
-
def extract_first_prompt_from_head(head)
|
|
440
|
+
def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold
|
|
439
441
|
command_fallback = nil
|
|
440
442
|
|
|
441
443
|
head.each_line do |line|
|
|
@@ -547,7 +549,7 @@ module ClaudeAgentSDK
|
|
|
547
549
|
[head, tail]
|
|
548
550
|
end
|
|
549
551
|
|
|
550
|
-
def build_session_info(file_path, head, tail, stat, project_path)
|
|
552
|
+
def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute
|
|
551
553
|
# User-set title (customTitle) wins over AI-generated title (aiTitle).
|
|
552
554
|
# Consult the head only when the tail has no occurrence of that field.
|
|
553
555
|
# Normalize blanks AFTER choosing the latest occurrence: an explicit
|
|
@@ -998,7 +1000,7 @@ module ClaudeAgentSDK
|
|
|
998
1000
|
# NotImplementedError (caller falls back to the slow path). Sessions missing
|
|
999
1001
|
# a sidecar or whose sidecar is stale (summary.mtime < the session's current
|
|
1000
1002
|
# mtime) are routed through gap-fill so the fold is recomputed from source.
|
|
1001
|
-
def list_sessions_via_summaries(store, project_key, project_path, limit, offset)
|
|
1003
|
+
def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill
|
|
1002
1004
|
begin
|
|
1003
1005
|
# Array(): a non-conformant store returning nil (e.g. a NULL JSONB read)
|
|
1004
1006
|
# degrades to gap-fill instead of crashing on nil.each, matching the
|
|
@@ -1046,7 +1048,7 @@ module ClaudeAgentSDK
|
|
|
1046
1048
|
# leaves a short page; loads stay bounded to ~offset + limit + (the dropped
|
|
1047
1049
|
# placeholders encountered before the page fills), preserving the fast
|
|
1048
1050
|
# path's "don't load every session" intent.
|
|
1049
|
-
def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset)
|
|
1051
|
+
def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset) # rubocop:disable Metrics/ParameterLists -- pagination state threaded explicitly
|
|
1050
1052
|
offset = 0 unless offset&.positive?
|
|
1051
1053
|
results = []
|
|
1052
1054
|
skipped = 0
|
|
@@ -1399,7 +1401,7 @@ module ClaudeAgentSDK
|
|
|
1399
1401
|
# threads (so a full pipe buffer can't deadlock git) and SIGKILL the
|
|
1400
1402
|
# child if the deadline passes. Matches Python's
|
|
1401
1403
|
# `subprocess.run(..., timeout=5)`.
|
|
1402
|
-
def detect_worktrees(path)
|
|
1404
|
+
def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill
|
|
1403
1405
|
stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
|
|
1404
1406
|
stdin.close
|
|
1405
1407
|
|
|
@@ -11,17 +11,26 @@ require_relative 'cli_installer'
|
|
|
11
11
|
|
|
12
12
|
module ClaudeAgentSDK
|
|
13
13
|
# Subprocess transport using Claude Code CLI
|
|
14
|
-
class SubprocessCLITransport < Transport
|
|
14
|
+
class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength -- subprocess lifecycle: discovery, spawn, IO, teardown
|
|
15
|
+
# @api private
|
|
15
16
|
DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
17
|
+
# @api private
|
|
16
18
|
MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
|
|
19
|
+
# @api private
|
|
17
20
|
SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
|
|
21
|
+
# @api private
|
|
18
22
|
CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH'
|
|
23
|
+
# @api private
|
|
19
24
|
VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
|
|
25
|
+
# @api private
|
|
20
26
|
RECENT_STDERR_LINES_LIMIT = 20
|
|
21
27
|
# After stdout EOF the child has closed (or lost) its last stdout handle,
|
|
22
28
|
# so it is normally already exiting; a CLI still running this long
|
|
23
29
|
# afterwards is wedged and gets the same TERM -> KILL ladder as #close.
|
|
30
|
+
#
|
|
31
|
+
# @api private
|
|
24
32
|
EOF_EXIT_GRACE_SECONDS = 5
|
|
33
|
+
# @api private
|
|
25
34
|
EOF_TERM_GRACE_SECONDS = 2
|
|
26
35
|
|
|
27
36
|
# Track live CLI subprocesses so we can terminate them when the parent Ruby
|
|
@@ -39,27 +48,36 @@ module ClaudeAgentSDK
|
|
|
39
48
|
# `self.class.register_active_process` would otherwise reach a nil mutex and
|
|
40
49
|
# raise mid-#connect, orphaning the just-spawned child. The base-class
|
|
41
50
|
# at_exit handler must be able to see every subprocess, a subclass's too.
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
42
53
|
ACTIVE_PROCESSES = Set.new
|
|
54
|
+
# @api private
|
|
43
55
|
ACTIVE_PROCESSES_MUTEX = Mutex.new
|
|
44
56
|
|
|
45
57
|
class << self
|
|
46
58
|
# Public readers (the test suite uses `described_class.active_processes`);
|
|
47
59
|
# they return the shared constants so subclasses observe the same objects.
|
|
60
|
+
#
|
|
61
|
+
# @api private
|
|
48
62
|
def active_processes
|
|
49
63
|
ACTIVE_PROCESSES
|
|
50
64
|
end
|
|
51
65
|
|
|
66
|
+
# @api private
|
|
52
67
|
def active_processes_mutex
|
|
53
68
|
ACTIVE_PROCESSES_MUTEX
|
|
54
69
|
end
|
|
55
70
|
|
|
56
71
|
# +wait_thr+ is the Process::Waiter returned by Open3.popen3.
|
|
72
|
+
#
|
|
73
|
+
# @api private
|
|
57
74
|
def register_active_process(wait_thr)
|
|
58
75
|
return unless wait_thr
|
|
59
76
|
|
|
60
77
|
active_processes_mutex.synchronize { active_processes.add(wait_thr) }
|
|
61
78
|
end
|
|
62
79
|
|
|
80
|
+
# @api private
|
|
63
81
|
def deregister_active_process(wait_thr)
|
|
64
82
|
return unless wait_thr
|
|
65
83
|
|
|
@@ -79,6 +97,8 @@ module ClaudeAgentSDK
|
|
|
79
97
|
# raises (e.g. ThreadError if reached from a trap context, or a
|
|
80
98
|
# concurrent-modification error from the unlocked read), honoring the
|
|
81
99
|
# "never interrupt interpreter shutdown" contract.
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
82
102
|
def kill_active_processes
|
|
83
103
|
active_processes.to_a.each do |wait_thr|
|
|
84
104
|
next unless wait_thr.alive?
|
|
@@ -94,6 +114,7 @@ module ClaudeAgentSDK
|
|
|
94
114
|
end
|
|
95
115
|
|
|
96
116
|
def initialize(options_or_prompt = nil, options = nil)
|
|
117
|
+
super() # Transport defines no state today; keep the chain intact if it ever does
|
|
97
118
|
# Support both new single-arg form and legacy two-arg form
|
|
98
119
|
@options = options.nil? ? options_or_prompt : options
|
|
99
120
|
@cli_path = @options.cli_path || find_cli
|
|
@@ -134,7 +155,9 @@ module ClaudeAgentSDK
|
|
|
134
155
|
# whatever version happens to be installed globally on the host.
|
|
135
156
|
# 3. `which claude`.
|
|
136
157
|
# 4. Well-known install locations.
|
|
137
|
-
|
|
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)
|
|
138
161
|
env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s
|
|
139
162
|
unless env_path.empty?
|
|
140
163
|
# Absolutize against the CURRENT working directory, which is where the
|
|
@@ -188,18 +211,17 @@ module ClaudeAgentSDK
|
|
|
188
211
|
return path if File.file?(path) && File.executable?(path)
|
|
189
212
|
end
|
|
190
213
|
|
|
191
|
-
raise CLINotFoundError
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
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"
|
|
203
225
|
end
|
|
204
226
|
|
|
205
227
|
# Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
|
|
@@ -209,6 +231,8 @@ module ClaudeAgentSDK
|
|
|
209
231
|
# group. Gate on the carrier's traceparent key (the W3C propagator writes
|
|
210
232
|
# it only for a valid span context) so a baggage-only carrier or a noop
|
|
211
233
|
# propagator preserves inherited env.
|
|
234
|
+
#
|
|
235
|
+
# @api private
|
|
212
236
|
def inject_otel_trace_context(process_env, custom_env)
|
|
213
237
|
return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
|
|
214
238
|
|
|
@@ -233,11 +257,12 @@ module ClaudeAgentSDK
|
|
|
233
257
|
# Exception). ScriptError too: NotImplementedError < ScriptError.
|
|
234
258
|
end
|
|
235
259
|
|
|
260
|
+
# @api private
|
|
236
261
|
def build_command
|
|
237
262
|
CommandBuilder.new(@cli_path, @options).build
|
|
238
263
|
end
|
|
239
264
|
|
|
240
|
-
def connect
|
|
265
|
+
def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order
|
|
241
266
|
return if @process
|
|
242
267
|
|
|
243
268
|
check_claude_version
|
|
@@ -246,7 +271,7 @@ module ClaudeAgentSDK
|
|
|
246
271
|
|
|
247
272
|
# Build environment
|
|
248
273
|
# Convert symbol keys to strings for spawn compatibility
|
|
249
|
-
custom_env = @options.env.transform_keys
|
|
274
|
+
custom_env = @options.env.transform_keys(&:to_s)
|
|
250
275
|
# Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK
|
|
251
276
|
# launches Claude Code from within an existing Claude Code terminal.
|
|
252
277
|
# NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
|
|
@@ -312,7 +337,7 @@ module ClaudeAgentSDK
|
|
|
312
337
|
|
|
313
338
|
# Always keep stdin open — streaming mode uses it for the control protocol
|
|
314
339
|
@ready = true
|
|
315
|
-
rescue Errno::ENOENT
|
|
340
|
+
rescue Errno::ENOENT
|
|
316
341
|
# Check if error is from cwd or CLI
|
|
317
342
|
if @cwd && !File.directory?(@cwd.to_s)
|
|
318
343
|
error = CLIConnectionError.new("Working directory does not exist: #{@cwd}")
|
|
@@ -332,6 +357,7 @@ module ClaudeAgentSDK
|
|
|
332
357
|
end
|
|
333
358
|
end
|
|
334
359
|
|
|
360
|
+
# @api private
|
|
335
361
|
def handle_stderr
|
|
336
362
|
return unless @stderr
|
|
337
363
|
|
|
@@ -375,6 +401,7 @@ module ClaudeAgentSDK
|
|
|
375
401
|
# Stream-level error (pipe closed mid-read); the loop naturally ends here.
|
|
376
402
|
end
|
|
377
403
|
|
|
404
|
+
# @api private
|
|
378
405
|
def drain_stderr_with_accumulation
|
|
379
406
|
return unless @stderr
|
|
380
407
|
|
|
@@ -387,7 +414,7 @@ module ClaudeAgentSDK
|
|
|
387
414
|
end
|
|
388
415
|
end
|
|
389
416
|
|
|
390
|
-
def close
|
|
417
|
+
def close # rubocop:disable Metrics/MethodLength -- teardown ordering is load-bearing
|
|
391
418
|
@ready = false
|
|
392
419
|
return unless @process
|
|
393
420
|
|
|
@@ -465,7 +492,9 @@ module ClaudeAgentSDK
|
|
|
465
492
|
# graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
|
|
466
493
|
# the reactor and suspends at several points; #close's ensure covers the
|
|
467
494
|
# cancellation-abandoned case.
|
|
468
|
-
|
|
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
|
|
469
498
|
cleanup_errors = []
|
|
470
499
|
|
|
471
500
|
# Kill stderr thread
|
|
@@ -543,9 +572,7 @@ module ClaudeAgentSDK
|
|
|
543
572
|
end
|
|
544
573
|
|
|
545
574
|
# Log any cleanup errors (non-fatal)
|
|
546
|
-
if cleanup_errors.any?
|
|
547
|
-
warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}"
|
|
548
|
-
end
|
|
575
|
+
warn "Claude SDK: Cleanup warnings: #{cleanup_errors.join(', ')}" if cleanup_errors.any?
|
|
549
576
|
|
|
550
577
|
self.class.deregister_active_process(@process)
|
|
551
578
|
end
|
|
@@ -558,6 +585,8 @@ module ClaudeAgentSDK
|
|
|
558
585
|
# left either way. The alive? guard also makes the delayed KILL
|
|
559
586
|
# pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
|
|
560
587
|
# the pid cannot have been recycled.
|
|
588
|
+
#
|
|
589
|
+
# @api private
|
|
561
590
|
def force_terminate_in_background(process, grace_seconds: 2)
|
|
562
591
|
return unless process
|
|
563
592
|
|
|
@@ -576,20 +605,18 @@ module ClaudeAgentSDK
|
|
|
576
605
|
end
|
|
577
606
|
|
|
578
607
|
Thread.new do
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
# Still wait for the waiter when exit raced the signal.
|
|
585
|
-
end
|
|
586
|
-
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.
|
|
587
613
|
end
|
|
588
|
-
|
|
589
|
-
nil # best-effort; retain ownership if termination/reaping failed
|
|
590
|
-
ensure
|
|
591
|
-
self.class.deregister_active_process(process) unless process.alive?
|
|
614
|
+
process.join(grace_seconds)
|
|
592
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?
|
|
593
620
|
end
|
|
594
621
|
end
|
|
595
622
|
|
|
@@ -598,6 +625,8 @@ module ClaudeAgentSDK
|
|
|
598
625
|
# across threads via Thread#raise and corrupts Async fiber-scheduler state
|
|
599
626
|
# (close is always called inside an Async task). Yields to the current
|
|
600
627
|
# Async task when one is active so the reactor keeps running.
|
|
628
|
+
#
|
|
629
|
+
# @api private
|
|
601
630
|
def wait_process_with_timeout(timeout_seconds, process = @process)
|
|
602
631
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
603
632
|
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
@@ -613,6 +642,8 @@ module ClaudeAgentSDK
|
|
|
613
642
|
# Process::Waiter#join(timeout): under a Fiber scheduler Ruby 3.2's
|
|
614
643
|
# Thread#join ignores its timeout and never returns for a live thread
|
|
615
644
|
# (probed on 3.2.0; 3.3/3.4 honor it).
|
|
645
|
+
#
|
|
646
|
+
# @api private
|
|
616
647
|
def process_exited_within?(process, seconds)
|
|
617
648
|
wait_process_with_timeout(seconds, process)
|
|
618
649
|
true
|
|
@@ -621,7 +652,7 @@ module ClaudeAgentSDK
|
|
|
621
652
|
end
|
|
622
653
|
|
|
623
654
|
def write(data)
|
|
624
|
-
raise CLIConnectionError,
|
|
655
|
+
raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
|
|
625
656
|
raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
|
|
626
657
|
|
|
627
658
|
# Snapshot @stdin under the lock so close() nilling it concurrently is
|
|
@@ -688,7 +719,7 @@ module ClaudeAgentSDK
|
|
|
688
719
|
# Ignore
|
|
689
720
|
end
|
|
690
721
|
|
|
691
|
-
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
|
|
692
723
|
return enum_for(:read_messages) unless block_given?
|
|
693
724
|
|
|
694
725
|
raise CLIConnectionError, 'Not connected' unless @process && @stdout
|
|
@@ -747,7 +778,7 @@ module ClaudeAgentSDK
|
|
|
747
778
|
buffer_length = json_buffer.bytesize
|
|
748
779
|
json_buffer = ''
|
|
749
780
|
raise CLIJSONDecodeError.new(
|
|
750
|
-
|
|
781
|
+
'JSON message exceeded maximum buffer size',
|
|
751
782
|
StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
|
|
752
783
|
)
|
|
753
784
|
end
|
|
@@ -865,6 +896,7 @@ module ClaudeAgentSDK
|
|
|
865
896
|
)
|
|
866
897
|
end
|
|
867
898
|
|
|
899
|
+
# @api private
|
|
868
900
|
def check_claude_version
|
|
869
901
|
# Mirrors Python's os.environ.get truthiness: any non-empty value skips,
|
|
870
902
|
# including '0'/'false'/' '; unset or empty string runs the check.
|
|
@@ -877,7 +909,7 @@ module ClaudeAgentSDK
|
|
|
877
909
|
# stdout chunk): this searches anywhere in stdout+stderr, so leading
|
|
878
910
|
# noise (a shim's own version line) could be mistaken for the CLI
|
|
879
911
|
# version. Pre-existing shape; the check is best-effort only.
|
|
880
|
-
if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
|
|
912
|
+
if (match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/))
|
|
881
913
|
version = match[1]
|
|
882
914
|
version_parts = version.split('.').map(&:to_i)
|
|
883
915
|
min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
|
|
@@ -887,7 +919,7 @@ module ClaudeAgentSDK
|
|
|
887
919
|
if (version_parts <=> min_parts).negative?
|
|
888
920
|
warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
|
|
889
921
|
"Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
|
|
890
|
-
|
|
922
|
+
'Some features may not work correctly.'
|
|
891
923
|
warn warning
|
|
892
924
|
end
|
|
893
925
|
end
|
|
@@ -914,7 +946,7 @@ module ClaudeAgentSDK
|
|
|
914
946
|
# read both pipes to EOF (pre-existing capture3 shape), so the deadline
|
|
915
947
|
# also bounds CLI exit. ensure always reaps the probe (mirrors Python's
|
|
916
948
|
# finally: terminate(); wait()).
|
|
917
|
-
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
|
|
918
950
|
stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
|
|
919
951
|
stdin.close
|
|
920
952
|
drainer = Thread.new { [stdout.read, stderr.read] }
|
|
@@ -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.
|
|
@@ -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
|