claude-agent-sdk 0.36.0 → 1.0.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 +34 -0
- data/README.md +7 -3
- data/UPGRADING-1.0.md +151 -0
- data/docs/client.md +26 -1
- data/docs/errors.md +6 -0
- data/docs/hooks-and-permissions.md +5 -3
- data/docs/mcp-servers.md +1 -2
- data/docs/sessions.md +101 -3
- data/docs/types.md +109 -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 +1 -1
- data/lib/claude_agent_sdk/errors.rb +10 -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 +31 -16
- 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 +236 -0
- data/lib/claude_agent_sdk/types/base.rb +322 -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 +62 -28
- data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
- data/sig/claude_agent_sdk/configuration.rbs +14 -0
- data/sig/claude_agent_sdk/errors.rbs +86 -0
- data/sig/claude_agent_sdk/observer.rbs +42 -0
- data/sig/claude_agent_sdk/railtie.rbs +10 -0
- data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
- data/sig/claude_agent_sdk/session_store.rbs +105 -0
- data/sig/claude_agent_sdk/streaming.rbs +15 -0
- data/sig/claude_agent_sdk/transport.rbs +98 -0
- data/sig/claude_agent_sdk/types/base.rbs +39 -0
- data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
- data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
- data/sig/claude_agent_sdk/types/messages.rbs +586 -0
- data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
- data/sig/claude_agent_sdk/types/options.rbs +288 -0
- data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
- data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
- data/sig/claude_agent_sdk.rbs +231 -0
- data/sig/manifest.yaml +5 -0
- metadata +32 -1
|
@@ -17,6 +17,8 @@ module ClaudeAgentSDK
|
|
|
17
17
|
# +config_dir+ is a temp directory laid out like ~/.claude/ — point the
|
|
18
18
|
# subprocess at it via CLAUDE_CONFIG_DIR. +resume_session_id+ is passed as
|
|
19
19
|
# --resume. Call #cleanup after the subprocess exits to remove the temp dir.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
20
22
|
class MaterializedResume
|
|
21
23
|
attr_reader :config_dir, :resume_session_id
|
|
22
24
|
|
|
@@ -40,7 +42,7 @@ module ClaudeAgentSDK
|
|
|
40
42
|
['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
|
|
41
43
|
FileUtils.rm_f(File.join(@config_dir, name))
|
|
42
44
|
end
|
|
43
|
-
warn
|
|
45
|
+
warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \
|
|
44
46
|
"Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
|
|
45
47
|
'deleting it — import it into your session store, then remove the directory.'
|
|
46
48
|
rescue StandardError => e
|
|
@@ -55,7 +57,9 @@ module ClaudeAgentSDK
|
|
|
55
57
|
# store. The CLI only resumes from a local file. This module loads the session
|
|
56
58
|
# from the store, writes it to a temp dir laid out like ~/.claude/, and returns
|
|
57
59
|
# the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
|
|
58
|
-
|
|
60
|
+
#
|
|
61
|
+
# @api private
|
|
62
|
+
module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materialization and its helpers
|
|
59
63
|
# User settings files seeded into the temp config dir. cowork_settings.json
|
|
60
64
|
# is the alternate filename the CLI reads in cowork-plugins mode.
|
|
61
65
|
SEEDED_SETTINGS_FILES = ['settings.json', 'cowork_settings.json'].freeze
|
|
@@ -115,8 +119,9 @@ module ClaudeAgentSDK
|
|
|
115
119
|
# Returns a MaterializedResume, or nil when no materialization is needed
|
|
116
120
|
# (no store, no resume/continue, store has no entries, or the resolved
|
|
117
121
|
# session id is not a valid UUID) — the caller then falls through to the
|
|
118
|
-
# normal spawn path. Raises
|
|
119
|
-
|
|
122
|
+
# normal spawn path. Raises SessionStoreError (#cause: the adapter's own
|
|
123
|
+
# exception or the timeout) if a store call fails or times out.
|
|
124
|
+
def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order
|
|
120
125
|
store = options.session_store
|
|
121
126
|
return nil if store.nil?
|
|
122
127
|
return nil if options.resume.nil? && !options.continue_conversation
|
|
@@ -155,7 +160,9 @@ module ClaudeAgentSDK
|
|
|
155
160
|
# so it can authenticate. Missing files are fine (API-key auth, etc.).
|
|
156
161
|
copy_auth_files(tmp_base, options.env)
|
|
157
162
|
|
|
158
|
-
|
|
163
|
+
if SessionStore.implements?(store, :list_subkeys)
|
|
164
|
+
materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
|
|
165
|
+
end
|
|
159
166
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
160
167
|
# Any failure after mkdtemp leaves tmp_base (which may already hold a
|
|
161
168
|
# .credentials.json copy) on disk with no path for the caller to clean
|
|
@@ -172,7 +179,7 @@ module ClaudeAgentSDK
|
|
|
172
179
|
|
|
173
180
|
# Load entries for session_id; return [session_id, entries] or nil if empty.
|
|
174
181
|
# Callers pass the result through encode_candidate before writing.
|
|
175
|
-
def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
|
|
182
|
+
def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
|
|
176
183
|
entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
|
|
177
184
|
store.load('project_key' => project_key, 'session_id' => session_id)
|
|
178
185
|
end
|
|
@@ -185,7 +192,7 @@ module ClaudeAgentSDK
|
|
|
185
192
|
# transcripts are mirrored as ordinary top-level keys and often have the
|
|
186
193
|
# highest mtime, so walk newest->oldest and skip them so --continue resumes
|
|
187
194
|
# the user's conversation, not a subagent's.
|
|
188
|
-
def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
|
|
195
|
+
def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- newest-first walk with sidechain and validity skips
|
|
189
196
|
sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling, wrapper) do
|
|
190
197
|
store.list_sessions(project_key)
|
|
191
198
|
end
|
|
@@ -284,7 +291,12 @@ module ClaudeAgentSDK
|
|
|
284
291
|
end
|
|
285
292
|
|
|
286
293
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
287
|
-
# re-raising failures/timeouts as
|
|
294
|
+
# re-raising failures/timeouts as SessionStoreError with context, the
|
|
295
|
+
# original as #cause (Ruby sets it: the raise is inside the rescue). Every
|
|
296
|
+
# StandardError is wrapped, the adapter's own RuntimeError included, so
|
|
297
|
+
# `rescue ClaudeSDKError` catches every materialization failure (0.x let
|
|
298
|
+
# a RuntimeError through unwrapped and raised the rest as bare
|
|
299
|
+
# RuntimeErrors, mirroring Python). The thread hop
|
|
288
300
|
# (the default for FiberBoundary with a timeout) both keeps the async
|
|
289
301
|
# scheduler out of the user's store code AND enforces load_timeout_ms
|
|
290
302
|
# unconditionally — including when materialization runs outside an Async
|
|
@@ -299,11 +311,11 @@ module ClaudeAgentSDK
|
|
|
299
311
|
def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &)
|
|
300
312
|
FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &)
|
|
301
313
|
rescue FiberBoundary::JoinTimeout
|
|
302
|
-
raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
|
|
303
|
-
rescue
|
|
314
|
+
raise SessionStoreError, "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
|
|
315
|
+
rescue SessionStoreError
|
|
304
316
|
raise
|
|
305
317
|
rescue StandardError => e
|
|
306
|
-
raise "#{what} failed during resume materialization: #{e}"
|
|
318
|
+
raise SessionStoreError, "#{what} failed during resume materialization: #{e.class}: #{e.message}"
|
|
307
319
|
end
|
|
308
320
|
|
|
309
321
|
# Write pre-encoded JSON lines (see encode_jsonl_lines), one per line,
|
|
@@ -337,7 +349,7 @@ module ClaudeAgentSDK
|
|
|
337
349
|
# missing files: they cannot exist, and raising here aborted every
|
|
338
350
|
# store-backed resume on a HOME-less host — even API-key auth, which
|
|
339
351
|
# needs none of them.
|
|
340
|
-
def copy_auth_files(tmp_base, opt_env)
|
|
352
|
+
def copy_auth_files(tmp_base, opt_env) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- each auth source is optional and copied independently
|
|
341
353
|
caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
|
|
342
354
|
home = caller_config_dir ? nil : Sessions.home_dir(opt_env)
|
|
343
355
|
source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
|
|
@@ -361,7 +373,9 @@ module ClaudeAgentSDK
|
|
|
361
373
|
write_redacted_credentials(creds_json, File.join(tmp_base, '.credentials.json'))
|
|
362
374
|
|
|
363
375
|
claude_json_dir = caller_config_dir || home
|
|
364
|
-
|
|
376
|
+
if claude_json_dir
|
|
377
|
+
copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json'))
|
|
378
|
+
end
|
|
365
379
|
|
|
366
380
|
# User settings carry apiKeyHelper (a fourth auth mechanism alongside
|
|
367
381
|
# .credentials.json / Keychain / env vars) plus the user's env, hooks and
|
|
@@ -584,7 +598,7 @@ module ClaudeAgentSDK
|
|
|
584
598
|
end
|
|
585
599
|
|
|
586
600
|
# Load and write all subagent transcripts/metadata under session_id.
|
|
587
|
-
def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
|
|
601
|
+
def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
|
|
588
602
|
session_dir = File.join(project_dir, session_id)
|
|
589
603
|
subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
|
|
590
604
|
store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
|
|
@@ -598,7 +612,8 @@ module ClaudeAgentSDK
|
|
|
598
612
|
next
|
|
599
613
|
end
|
|
600
614
|
|
|
601
|
-
sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}",
|
|
615
|
+
sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}",
|
|
616
|
+
scheduling, wrapper) do
|
|
602
617
|
store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
|
|
603
618
|
end
|
|
604
619
|
next if sub_entries.nil? || sub_entries.empty?
|
|
@@ -634,7 +649,7 @@ module ClaudeAgentSDK
|
|
|
634
649
|
def encode_agent_metadata(meta_content, subpath)
|
|
635
650
|
JSON.generate(meta_content)
|
|
636
651
|
rescue JSON::JSONError => e
|
|
637
|
-
warn
|
|
652
|
+
warn 'Claude SDK: [SessionStore] resume: skipping unserializable agent metadata ' \
|
|
638
653
|
"for subpath #{subpath} (#{e.class}: #{e.message})"
|
|
639
654
|
nil
|
|
640
655
|
end
|
|
@@ -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] }
|