claude-agent-sdk 0.17.0 → 0.18.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 +56 -0
- data/README.md +4 -2
- data/docs/configuration.md +13 -2
- data/docs/observability.md +28 -4
- data/docs/sessions.md +15 -2
- data/lib/claude_agent_sdk/command_builder.rb +69 -22
- data/lib/claude_agent_sdk/fiber_boundary.rb +39 -1
- data/lib/claude_agent_sdk/instrumentation/otel.rb +97 -23
- data/lib/claude_agent_sdk/message_parser.rb +4 -1
- data/lib/claude_agent_sdk/observer.rb +23 -3
- data/lib/claude_agent_sdk/query.rb +223 -88
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +232 -181
- data/lib/claude_agent_sdk/session_store.rb +4 -0
- data/lib/claude_agent_sdk/sessions.rb +144 -24
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +184 -50
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -1
- data/lib/claude_agent_sdk/types.rb +43 -5
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +359 -93
- metadata +12 -6
|
@@ -134,6 +134,11 @@ module ClaudeAgentSDK
|
|
|
134
134
|
# realpath can't resolve it (e.g. the directory does not exist yet) — Ruby's
|
|
135
135
|
# File.realpath raises on missing paths whereas Python's os.path.realpath is
|
|
136
136
|
# lexical for the missing suffix, so expand_path restores that behavior.
|
|
137
|
+
# Known divergence: for a MISSING path Python still resolves symlinks in
|
|
138
|
+
# the existing prefix (so a deleted /tmp/proj on macOS canonicalizes to
|
|
139
|
+
# /private/tmp/proj and its project dir is found); the expand_path
|
|
140
|
+
# fallback resolves none, so deleted-directory lookups under symlinked
|
|
141
|
+
# prefixes can miss.
|
|
137
142
|
def canonicalize_path(dir)
|
|
138
143
|
File.realpath(dir).unicode_normalize(:nfc)
|
|
139
144
|
rescue SystemCallError
|
|
@@ -153,9 +158,14 @@ module ClaudeAgentSDK
|
|
|
153
158
|
sanitize_path(canonicalize_path(directory.nil? ? '.' : directory.to_s))
|
|
154
159
|
end
|
|
155
160
|
|
|
156
|
-
# Get the Claude config directory
|
|
161
|
+
# Get the Claude config directory (respects CLAUDE_CONFIG_DIR; an empty
|
|
162
|
+
# value is treated as unset, matching the Node CLI and the Python SDK).
|
|
163
|
+
# NFC-normalized on both branches like Python's _get_claude_config_home_dir.
|
|
157
164
|
def config_dir
|
|
158
|
-
ENV.fetch('CLAUDE_CONFIG_DIR',
|
|
165
|
+
dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil)
|
|
166
|
+
return dir.unicode_normalize(:nfc) if dir && !dir.empty?
|
|
167
|
+
|
|
168
|
+
File.expand_path('~/.claude').unicode_normalize(:nfc)
|
|
159
169
|
end
|
|
160
170
|
|
|
161
171
|
# Find the project directory for a given path
|
|
@@ -319,10 +329,13 @@ module ClaudeAgentSDK
|
|
|
319
329
|
tag_value = tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil
|
|
320
330
|
tag_value = nil if tag_value && tag_value.empty?
|
|
321
331
|
|
|
322
|
-
# created_at from first
|
|
323
|
-
# than stat().birthtime which is unsupported on some
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
# created_at from the first ISO timestamp found in the head (epoch ms).
|
|
333
|
+
# More reliable than stat().birthtime which is unsupported on some
|
|
334
|
+
# filesystems. Scans the whole head rather than only the first line
|
|
335
|
+
# because the first record may be a metadata-only entry (e.g.
|
|
336
|
+
# permission-mode) with no timestamp field; the first user/assistant
|
|
337
|
+
# record that follows does carry one (Python #907).
|
|
338
|
+
first_timestamp = extract_json_string_field(head, 'timestamp', last: false)
|
|
326
339
|
created_at = parse_iso_timestamp_ms(first_timestamp) if first_timestamp
|
|
327
340
|
|
|
328
341
|
SDKSessionInfo.new(
|
|
@@ -433,7 +446,14 @@ module ClaudeAgentSDK
|
|
|
433
446
|
file_path = find_session_file(session_id, directory)
|
|
434
447
|
return [] unless file_path && File.exist?(file_path)
|
|
435
448
|
|
|
436
|
-
|
|
449
|
+
begin
|
|
450
|
+
entries = parse_jsonl_entries(file_path)
|
|
451
|
+
rescue SystemCallError
|
|
452
|
+
# TOCTOU between resolution and read (file deleted by another
|
|
453
|
+
# process) — return [] like Python's except OSError, and like the
|
|
454
|
+
# sibling get_subagent_messages.
|
|
455
|
+
return []
|
|
456
|
+
end
|
|
437
457
|
chain = build_conversation_chain(entries)
|
|
438
458
|
messages = filter_visible_messages(chain)
|
|
439
459
|
|
|
@@ -443,6 +463,55 @@ module ClaudeAgentSDK
|
|
|
443
463
|
messages
|
|
444
464
|
end
|
|
445
465
|
|
|
466
|
+
# List subagent IDs recorded for a session on local disk (counterpart to
|
|
467
|
+
# list_subagents_from_store). Scans
|
|
468
|
+
# <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested
|
|
469
|
+
# workflows/<runId>/ paths, in sorted walk order. Mirrors the Python SDK's
|
|
470
|
+
# list_subagents (#825) — no dedupe (the store variant dedupes because
|
|
471
|
+
# adapter subkey ordering is adapter-defined; the sorted disk walk is
|
|
472
|
+
# already deterministic).
|
|
473
|
+
# @param session_id [String] The session UUID
|
|
474
|
+
# @param directory [String, nil] Working directory to search in (strictly
|
|
475
|
+
# scopes to that project + its worktrees; nil searches all projects)
|
|
476
|
+
# @return [Array<String>] Subagent IDs
|
|
477
|
+
def list_subagents(session_id:, directory: nil)
|
|
478
|
+
return [] unless session_id.match?(UUID_RE)
|
|
479
|
+
|
|
480
|
+
subagents_dir = resolve_subagents_dir(session_id, directory)
|
|
481
|
+
return [] if subagents_dir.nil?
|
|
482
|
+
|
|
483
|
+
collect_agent_files(subagents_dir).map(&:first)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Read a subagent's conversation messages from local disk (counterpart to
|
|
487
|
+
# get_subagent_messages_from_store). First match in sorted walk order wins
|
|
488
|
+
# when the same agent id exists at multiple depths (mirrors Python).
|
|
489
|
+
# @param session_id [String] The session UUID
|
|
490
|
+
# @param agent_id [String] The subagent ID (without the agent- prefix)
|
|
491
|
+
# @param directory [String, nil] Working directory to search in
|
|
492
|
+
# @param limit [Integer, nil] Maximum number of messages
|
|
493
|
+
# @param offset [Integer] Number of messages to skip
|
|
494
|
+
# @return [Array<SessionMessage>] Ordered messages from the subagent
|
|
495
|
+
def get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
|
|
496
|
+
return [] unless session_id.match?(UUID_RE)
|
|
497
|
+
return [] if agent_id.nil? || agent_id.empty?
|
|
498
|
+
|
|
499
|
+
subagents_dir = resolve_subagents_dir(session_id, directory)
|
|
500
|
+
return [] if subagents_dir.nil?
|
|
501
|
+
|
|
502
|
+
_id, path = collect_agent_files(subagents_dir).find { |id, _path| id == agent_id }
|
|
503
|
+
return [] if path.nil?
|
|
504
|
+
|
|
505
|
+
begin
|
|
506
|
+
entries = parse_jsonl_entries(path)
|
|
507
|
+
rescue SystemCallError
|
|
508
|
+
# TOCTOU between the walk and the read (mirrors Python's
|
|
509
|
+
# `except OSError: return []`).
|
|
510
|
+
return []
|
|
511
|
+
end
|
|
512
|
+
entries_to_subagent_messages(entries, limit, offset)
|
|
513
|
+
end
|
|
514
|
+
|
|
446
515
|
# ---- SessionStore-backed reads (store counterparts to the disk readers) ----
|
|
447
516
|
|
|
448
517
|
# List sessions from a SessionStore. Store-backed counterpart to
|
|
@@ -854,7 +923,10 @@ module ClaudeAgentSDK
|
|
|
854
923
|
end
|
|
855
924
|
|
|
856
925
|
def get_session_info_for_directory(file_name, directory)
|
|
857
|
-
|
|
926
|
+
# canonicalize_path (not raw realpath): a nonexistent directory must
|
|
927
|
+
# canonicalize lexically and yield nil from find_project_dir — Python's
|
|
928
|
+
# os.path.realpath never raises here.
|
|
929
|
+
canonical = canonicalize_path(directory)
|
|
858
930
|
project_dir = find_project_dir(canonical)
|
|
859
931
|
if project_dir
|
|
860
932
|
info = read_session_lite(File.join(project_dir, file_name), canonical)
|
|
@@ -876,7 +948,7 @@ module ClaudeAgentSDK
|
|
|
876
948
|
end
|
|
877
949
|
|
|
878
950
|
def list_sessions_for_directory(directory, include_worktrees)
|
|
879
|
-
path =
|
|
951
|
+
path = canonicalize_path(directory)
|
|
880
952
|
|
|
881
953
|
worktree_paths = []
|
|
882
954
|
worktree_paths = detect_worktrees(path) if include_worktrees
|
|
@@ -978,36 +1050,83 @@ module ClaudeAgentSDK
|
|
|
978
1050
|
projects_dir = File.join(config_dir, 'projects')
|
|
979
1051
|
return nil unless File.directory?(projects_dir)
|
|
980
1052
|
|
|
1053
|
+
file_name = "#{session_id}.jsonl"
|
|
1054
|
+
|
|
981
1055
|
if directory
|
|
982
|
-
path =
|
|
983
|
-
|
|
984
|
-
if
|
|
985
|
-
candidate = File.join(project_dir, "#{session_id}.jsonl")
|
|
986
|
-
return candidate if File.exist?(candidate)
|
|
987
|
-
end
|
|
1056
|
+
path = canonicalize_path(directory)
|
|
1057
|
+
found = stat_candidate(find_project_dir(path), file_name)
|
|
1058
|
+
return found if found
|
|
988
1059
|
|
|
989
|
-
# Try worktrees
|
|
990
1060
|
detect_worktrees(path).each do |wt_path|
|
|
991
|
-
|
|
992
|
-
next unless pd
|
|
1061
|
+
next if wt_path == path # already tried above
|
|
993
1062
|
|
|
994
|
-
|
|
995
|
-
return
|
|
1063
|
+
found = stat_candidate(find_project_dir(wt_path), file_name)
|
|
1064
|
+
return found if found
|
|
996
1065
|
end
|
|
1066
|
+
|
|
1067
|
+
# An explicit directory strictly scopes the search — never fall
|
|
1068
|
+
# through to the global scan, which could resolve an unrelated
|
|
1069
|
+
# project's same-id session (mirrors Python's _resolve_session_file_path).
|
|
1070
|
+
return nil
|
|
997
1071
|
end
|
|
998
1072
|
|
|
999
|
-
#
|
|
1073
|
+
# No directory provided — search all project directories.
|
|
1000
1074
|
Dir.children(projects_dir).each do |child|
|
|
1001
1075
|
dir = File.join(projects_dir, child)
|
|
1002
1076
|
next unless File.directory?(dir)
|
|
1003
1077
|
|
|
1004
|
-
|
|
1005
|
-
return
|
|
1078
|
+
found = stat_candidate(dir, file_name)
|
|
1079
|
+
return found if found
|
|
1006
1080
|
end
|
|
1007
1081
|
|
|
1008
1082
|
nil
|
|
1009
1083
|
end
|
|
1010
1084
|
|
|
1085
|
+
# Mirrors Python's _stat_candidate: a candidate counts only when it
|
|
1086
|
+
# exists AND is non-empty — a 0-byte stub in one project dir must not
|
|
1087
|
+
# stop the search when the real transcript lives under another
|
|
1088
|
+
# worktree's project dir (same hazard SessionMutations.try_append guards).
|
|
1089
|
+
def stat_candidate(project_dir, file_name)
|
|
1090
|
+
return nil if project_dir.nil?
|
|
1091
|
+
|
|
1092
|
+
candidate = File.join(project_dir, file_name)
|
|
1093
|
+
File.size(candidate).positive? ? candidate : nil
|
|
1094
|
+
rescue SystemCallError
|
|
1095
|
+
nil
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# Resolve the on-disk subagents directory for a session:
|
|
1099
|
+
# <projectDir>/<sessionId>/subagents (mirrors Python's
|
|
1100
|
+
# _resolve_subagents_dir). nil when the session transcript can't be found.
|
|
1101
|
+
def resolve_subagents_dir(session_id, directory)
|
|
1102
|
+
resolved = find_session_file(session_id, directory)
|
|
1103
|
+
return nil if resolved.nil?
|
|
1104
|
+
|
|
1105
|
+
File.join(resolved.delete_suffix('.jsonl'), 'subagents')
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# Depth-first sorted walk collecting [agent_id, path] pairs for
|
|
1109
|
+
# agent-<id>.jsonl files, recursing into subdirectories (e.g.
|
|
1110
|
+
# workflows/<runId>/) in the same sorted interleave. Mirrors Python's
|
|
1111
|
+
# _collect_agent_files: no dedupe; [] for a missing/unreadable base dir.
|
|
1112
|
+
def collect_agent_files(base_dir, results = [])
|
|
1113
|
+
begin
|
|
1114
|
+
children = Dir.children(base_dir).sort
|
|
1115
|
+
rescue SystemCallError
|
|
1116
|
+
return results
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
children.each do |name|
|
|
1120
|
+
path = File.join(base_dir, name)
|
|
1121
|
+
if File.directory?(path)
|
|
1122
|
+
collect_agent_files(path, results)
|
|
1123
|
+
elsif File.file?(path) && name.start_with?('agent-') && name.end_with?('.jsonl')
|
|
1124
|
+
results << [name.delete_prefix('agent-').delete_suffix('.jsonl'), path]
|
|
1125
|
+
end
|
|
1126
|
+
end
|
|
1127
|
+
results
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1011
1130
|
def parse_jsonl_entries(file_path)
|
|
1012
1131
|
entries = []
|
|
1013
1132
|
|
|
@@ -1113,7 +1232,8 @@ module ClaudeAgentSDK
|
|
|
1113
1232
|
private_class_method :get_session_info_for_directory,
|
|
1114
1233
|
:list_sessions_for_directory, :list_all_sessions,
|
|
1115
1234
|
:deduplicate_sessions,
|
|
1116
|
-
:find_session_file, :
|
|
1235
|
+
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1236
|
+
:collect_agent_files, :parse_jsonl_entries,
|
|
1117
1237
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1118
1238
|
:filter_visible_messages, :read_head_tail, :build_session_info,
|
|
1119
1239
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
@@ -14,6 +14,8 @@ module ClaudeAgentSDK
|
|
|
14
14
|
class SubprocessCLITransport < Transport
|
|
15
15
|
DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
16
16
|
MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
|
|
17
|
+
SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
|
|
18
|
+
VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2)
|
|
17
19
|
RECENT_STDERR_LINES_LIMIT = 20
|
|
18
20
|
|
|
19
21
|
# Track live CLI subprocesses so we can terminate them when the parent Ruby
|
|
@@ -143,6 +145,37 @@ module ClaudeAgentSDK
|
|
|
143
145
|
)
|
|
144
146
|
end
|
|
145
147
|
|
|
148
|
+
# Inject W3C trace context (TRACEPARENT/TRACESTATE, plus BAGGAGE) into the
|
|
149
|
+
# subprocess env when an OTel span is active. Guard via defined? +
|
|
150
|
+
# respond_to?, not require: an active span implies the constant is loaded,
|
|
151
|
+
# and requiring here would break against the test mock / optional gem
|
|
152
|
+
# group. Gate on the carrier's traceparent key (the W3C propagator writes
|
|
153
|
+
# it only for a valid span context) so a baggage-only carrier or a noop
|
|
154
|
+
# propagator preserves inherited env.
|
|
155
|
+
def inject_otel_trace_context(process_env, custom_env)
|
|
156
|
+
return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:propagation)
|
|
157
|
+
|
|
158
|
+
carrier = {}
|
|
159
|
+
OpenTelemetry.propagation.inject(carrier)
|
|
160
|
+
return unless carrier.key?('traceparent')
|
|
161
|
+
|
|
162
|
+
# Active span: scrub stale inherited W3C context (CI/k8s ambient env)
|
|
163
|
+
# before writing fresh values, so an inherited TRACESTATE is never
|
|
164
|
+
# paired with a new TRACEPARENT. nil actively unsets (spawn overlay
|
|
165
|
+
# semantics — see the CLAUDECODE note in #connect; Python pops from a
|
|
166
|
+
# complete env dict instead). Explicit options.env keys always win.
|
|
167
|
+
%w[TRACEPARENT TRACESTATE].each do |key|
|
|
168
|
+
process_env[key] = nil unless custom_env.key?(key)
|
|
169
|
+
end
|
|
170
|
+
carrier.each do |key, value|
|
|
171
|
+
env_key = key.upcase
|
|
172
|
+
process_env[env_key] = value unless custom_env.key?(env_key)
|
|
173
|
+
end
|
|
174
|
+
rescue StandardError, ScriptError
|
|
175
|
+
# Best-effort tracing must never break connect() (Python: except
|
|
176
|
+
# Exception). ScriptError too: NotImplementedError < ScriptError.
|
|
177
|
+
end
|
|
178
|
+
|
|
146
179
|
def build_command
|
|
147
180
|
CommandBuilder.new(@cli_path, @options).build
|
|
148
181
|
end
|
|
@@ -161,8 +194,18 @@ module ClaudeAgentSDK
|
|
|
161
194
|
# launches Claude Code from within an existing Claude Code terminal.
|
|
162
195
|
# NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays
|
|
163
196
|
# the env hash on top of the parent environment; a nil value actively unsets.
|
|
164
|
-
|
|
165
|
-
|
|
197
|
+
# ENTRYPOINT defaults to sdk-rb regardless of inherited process env
|
|
198
|
+
# (the old ||= let an inherited 'cli' win and mis-attribute telemetry);
|
|
199
|
+
# options.env may still override it. VERSION is merged last: always
|
|
200
|
+
# set by the SDK, never overridable (Python merge-order parity).
|
|
201
|
+
process_env = ENV.to_h
|
|
202
|
+
.merge('CLAUDECODE' => nil, 'CLAUDE_CODE_ENTRYPOINT' => 'sdk-rb')
|
|
203
|
+
.merge(custom_env)
|
|
204
|
+
.merge('CLAUDE_AGENT_SDK_VERSION' => VERSION)
|
|
205
|
+
# Propagate the active OTel trace context to the CLI so its spans parent
|
|
206
|
+
# under the caller's distributed trace (Python SDK #821 parity). No-op
|
|
207
|
+
# when opentelemetry is not loaded or there is no active span.
|
|
208
|
+
inject_otel_trace_context(process_env, custom_env)
|
|
166
209
|
process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
|
|
167
210
|
process_env['PWD'] = @cwd.to_s if @cwd
|
|
168
211
|
|
|
@@ -171,9 +214,22 @@ module ClaudeAgentSDK
|
|
|
171
214
|
|
|
172
215
|
begin
|
|
173
216
|
# Start process using Open3
|
|
174
|
-
|
|
217
|
+
# :uid mirrors Python's anyio.open_process(user=...): String username
|
|
218
|
+
# or Integer uid (Unix; requires privileges — typically root). The
|
|
219
|
+
# .compact is mandatory: uid: nil raises TypeError on every connect.
|
|
220
|
+
# On Windows spawn raises for :uid, wrapped below into
|
|
221
|
+
# CLIConnectionError — fail-loud instead of the old silent ignore.
|
|
222
|
+
opts = { chdir: @cwd&.to_s, uid: @options.user }.compact
|
|
175
223
|
|
|
176
224
|
@stdin, @stdout, @stderr, @process = Open3.popen3(process_env, *cmd, opts)
|
|
225
|
+
# The CLI emits UTF-8 regardless of the parent locale. popen3 pipes
|
|
226
|
+
# default to Encoding.default_external (US-ASCII under LANG=C/LC_ALL=C
|
|
227
|
+
# — minimal Docker images, systemd, CI), which makes String#strip on
|
|
228
|
+
# multibyte CLI output raise Encoding::CompatibilityError and kill the
|
|
229
|
+
# read loop (its rescue only catches IOError). Mirrors the Python
|
|
230
|
+
# SDK's TextReceiveStream(stdout), which always decodes UTF-8.
|
|
231
|
+
@stdout&.set_encoding(Encoding::UTF_8)
|
|
232
|
+
@stderr&.set_encoding(Encoding::UTF_8)
|
|
177
233
|
self.class.register_active_process(@process)
|
|
178
234
|
|
|
179
235
|
# Always drain stderr to prevent pipe buffer deadlock.
|
|
@@ -209,7 +265,10 @@ module ClaudeAgentSDK
|
|
|
209
265
|
error = CLINotFoundError.new("Claude Code not found at: #{@cli_path}")
|
|
210
266
|
@exit_error = error
|
|
211
267
|
raise error
|
|
212
|
-
rescue StandardError => e
|
|
268
|
+
rescue StandardError, NotImplementedError => e
|
|
269
|
+
# NotImplementedError < ScriptError, not StandardError (the trap this
|
|
270
|
+
# repo keeps hitting): spawn raises it for :uid on platforms without
|
|
271
|
+
# setuid (Windows), and it must wrap like every other spawn failure.
|
|
213
272
|
error = CLIConnectionError.new("Failed to start Claude Code: #{e}")
|
|
214
273
|
@exit_error = error
|
|
215
274
|
raise error
|
|
@@ -219,7 +278,7 @@ module ClaudeAgentSDK
|
|
|
219
278
|
def handle_stderr
|
|
220
279
|
return unless @stderr
|
|
221
280
|
|
|
222
|
-
@stderr.each_line do |line|
|
|
281
|
+
@stderr.each_line("\n", @max_buffer_size + 1) do |line|
|
|
223
282
|
line_str = line.chomp
|
|
224
283
|
next if line_str.empty?
|
|
225
284
|
|
|
@@ -257,7 +316,7 @@ module ClaudeAgentSDK
|
|
|
257
316
|
def drain_stderr_with_accumulation
|
|
258
317
|
return unless @stderr
|
|
259
318
|
|
|
260
|
-
@stderr.each_line do |line|
|
|
319
|
+
@stderr.each_line("\n", @max_buffer_size + 1) do |line|
|
|
261
320
|
line_str = line.chomp
|
|
262
321
|
next if line_str.empty?
|
|
263
322
|
|
|
@@ -400,14 +459,21 @@ module ClaudeAgentSDK
|
|
|
400
459
|
end
|
|
401
460
|
|
|
402
461
|
def end_input
|
|
403
|
-
|
|
462
|
+
# Under @stdin_mutex like write/close (the transport's documented
|
|
463
|
+
# locking protocol; Python's end_input takes _write_lock too). The
|
|
464
|
+
# nil-guard must live INSIDE the critical section or the TOCTOU
|
|
465
|
+
# returns. NOTE: non-reentrant — close() inlines its own stdin
|
|
466
|
+
# handling and must never delegate here.
|
|
467
|
+
@stdin_mutex.synchronize do
|
|
468
|
+
return unless @stdin
|
|
404
469
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
470
|
+
begin
|
|
471
|
+
@stdin.close
|
|
472
|
+
rescue StandardError
|
|
473
|
+
# Ignore
|
|
474
|
+
end
|
|
475
|
+
@stdin = nil
|
|
409
476
|
end
|
|
410
|
-
@stdin = nil
|
|
411
477
|
end
|
|
412
478
|
|
|
413
479
|
def read_messages(&block)
|
|
@@ -418,43 +484,59 @@ module ClaudeAgentSDK
|
|
|
418
484
|
json_buffer = ''
|
|
419
485
|
|
|
420
486
|
begin
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
487
|
+
# The limit bounds per-read allocation: a line longer than
|
|
488
|
+
# max_buffer_size+1 arrives as bounded chunks that the existing
|
|
489
|
+
# accumulation + cap machinery below handles (mirrors Python, where
|
|
490
|
+
# TextReceiveStream yields <=64KB chunks and the cap fires
|
|
491
|
+
# incrementally). +1 so an exactly-max line plus "\n" arrives whole.
|
|
492
|
+
# With UTF-8 external encoding Ruby extends a few bytes past the
|
|
493
|
+
# limit rather than splitting a multibyte char. Without the limit,
|
|
494
|
+
# an oversized line was fully allocated BEFORE the 1MB cap could
|
|
495
|
+
# fire — unbounded memory on hostile/buggy stdout.
|
|
496
|
+
@stdout.each_line("\n", @max_buffer_size + 1) do |line|
|
|
497
|
+
# Position-aware whitespace handling: a chunk of an over-limit line
|
|
498
|
+
# must keep its interior whitespace — a blanket per-chunk strip
|
|
499
|
+
# deleted spaces inside JSON strings straddling the chunk boundary
|
|
500
|
+
# and could let a just-over-cap line PARSE with bytes silently
|
|
501
|
+
# missing instead of raising. Only safe edges are trimmed: full
|
|
502
|
+
# single-chunk lines strip both ends (the common path, original
|
|
503
|
+
# behavior); a truncated line-initial chunk keeps its tail; a
|
|
504
|
+
# continuation chunk keeps its head and only drops the newline.
|
|
505
|
+
ends_line = line.end_with?("\n")
|
|
506
|
+
if json_buffer.empty?
|
|
507
|
+
chunk = ends_line ? line.strip : line.lstrip
|
|
508
|
+
next if chunk.empty?
|
|
509
|
+
|
|
510
|
+
# When no partial JSON is buffered, the line must start with `{`
|
|
511
|
+
# to be a valid stream-json message. Stray stderr-like text
|
|
433
512
|
# (e.g., debug warnings the CLI occasionally writes to stdout)
|
|
434
513
|
# would otherwise be appended into json_buffer, poisoning every
|
|
435
514
|
# subsequent parse until the buffer overflows. Matches the Python
|
|
436
|
-
# SDK's `if not json_buffer and not json_line.startswith("{")
|
|
437
|
-
next
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if json_buffer.bytesize > @max_buffer_size
|
|
442
|
-
buffer_length = json_buffer.bytesize
|
|
443
|
-
json_buffer = ''
|
|
444
|
-
raise CLIJSONDecodeError.new(
|
|
445
|
-
"JSON message exceeded maximum buffer size",
|
|
446
|
-
StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
|
|
447
|
-
)
|
|
448
|
-
end
|
|
515
|
+
# SDK's `if not json_buffer and not json_line.startswith("{")`.
|
|
516
|
+
next unless chunk.start_with?('{')
|
|
517
|
+
else
|
|
518
|
+
chunk = ends_line ? line.chomp : line
|
|
519
|
+
end
|
|
449
520
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
521
|
+
json_buffer += chunk
|
|
522
|
+
|
|
523
|
+
if json_buffer.bytesize > @max_buffer_size
|
|
524
|
+
buffer_length = json_buffer.bytesize
|
|
525
|
+
json_buffer = ''
|
|
526
|
+
raise CLIJSONDecodeError.new(
|
|
527
|
+
"JSON message exceeded maximum buffer size",
|
|
528
|
+
StandardError.new("Buffer size #{buffer_length} exceeds limit #{@max_buffer_size}")
|
|
529
|
+
)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
data = JSON.parse(json_buffer, symbolize_names: true)
|
|
534
|
+
json_buffer = ''
|
|
535
|
+
yield data
|
|
536
|
+
rescue JSON::ParserError
|
|
537
|
+
# Continue accumulating (multi-line JSON, or a truncated chunk
|
|
538
|
+
# awaiting the rest of its line)
|
|
539
|
+
next
|
|
458
540
|
end
|
|
459
541
|
end
|
|
460
542
|
rescue IOError
|
|
@@ -499,23 +581,34 @@ module ClaudeAgentSDK
|
|
|
499
581
|
end
|
|
500
582
|
|
|
501
583
|
def check_claude_version
|
|
584
|
+
# Mirrors Python's os.environ.get truthiness: any non-empty value skips,
|
|
585
|
+
# including '0'/'false'/' '; unset or empty string runs the check.
|
|
586
|
+
skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil)
|
|
587
|
+
return if skip && !skip.empty?
|
|
588
|
+
|
|
502
589
|
begin
|
|
503
|
-
|
|
504
|
-
|
|
590
|
+
output = capture_cli_version_output
|
|
591
|
+
# Residual divergence from Python (anchored re.match over the first
|
|
592
|
+
# stdout chunk): this searches anywhere in stdout+stderr, so leading
|
|
593
|
+
# noise (a shim's own version line) could be mistaken for the CLI
|
|
594
|
+
# version. Pre-existing shape; the check is best-effort only.
|
|
505
595
|
if match = output.match(/([0-9]+\.[0-9]+\.[0-9]+)/)
|
|
506
596
|
version = match[1]
|
|
507
597
|
version_parts = version.split('.').map(&:to_i)
|
|
508
598
|
min_parts = MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
|
|
509
599
|
|
|
510
|
-
|
|
511
|
-
|
|
600
|
+
# Array has no #< — the old `version_parts < min_parts` raised
|
|
601
|
+
# NoMethodError into the blanket rescue, so the warning never fired.
|
|
602
|
+
if (version_parts <=> min_parts).negative?
|
|
603
|
+
warning = "Warning: Claude Code version #{version} at #{@cli_path} is unsupported in the Agent SDK. " \
|
|
512
604
|
"Minimum required version is #{MINIMUM_CLAUDE_CODE_VERSION}. " \
|
|
513
605
|
"Some features may not work correctly."
|
|
514
606
|
warn warning
|
|
515
607
|
end
|
|
516
608
|
end
|
|
517
609
|
rescue StandardError
|
|
518
|
-
# Ignore version check errors
|
|
610
|
+
# Ignore version check errors — including Timeout::Error from the
|
|
611
|
+
# probe deadline, mirroring Python's `except Exception: pass`.
|
|
519
612
|
end
|
|
520
613
|
end
|
|
521
614
|
|
|
@@ -525,6 +618,47 @@ module ClaudeAgentSDK
|
|
|
525
618
|
|
|
526
619
|
private
|
|
527
620
|
|
|
621
|
+
# Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same
|
|
622
|
+
# injection-safety as capture3. Raises Timeout::Error past
|
|
623
|
+
# VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's
|
|
624
|
+
# blanket rescue, mirroring Python's `except Exception: pass` around
|
|
625
|
+
# anyio.fail_after(2)). Monotonic-deadline poll instead of stdlib
|
|
626
|
+
# Timeout.timeout for the same reason as wait_process_with_timeout:
|
|
627
|
+
# Thread#raise corrupts Async fiber-scheduler state, and connect runs
|
|
628
|
+
# inside the reactor. Divergence: Python takes a single stdout chunk; we
|
|
629
|
+
# read both pipes to EOF (pre-existing capture3 shape), so the deadline
|
|
630
|
+
# also bounds CLI exit. ensure always reaps the probe (mirrors Python's
|
|
631
|
+
# finally: terminate(); wait()).
|
|
632
|
+
def capture_cli_version_output
|
|
633
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v')
|
|
634
|
+
stdin.close
|
|
635
|
+
drainer = Thread.new { [stdout.read, stderr.read] }
|
|
636
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS
|
|
637
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
638
|
+
until drainer.join(0)
|
|
639
|
+
raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
640
|
+
|
|
641
|
+
task ? task.sleep(0.05) : sleep(0.05)
|
|
642
|
+
end
|
|
643
|
+
out, err = drainer.value
|
|
644
|
+
(out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip
|
|
645
|
+
ensure
|
|
646
|
+
if wait_thr&.alive?
|
|
647
|
+
begin
|
|
648
|
+
Process.kill('TERM', wait_thr.pid)
|
|
649
|
+
Process.kill('KILL', wait_thr.pid) if !wait_thr.join(0.5) && wait_thr.alive?
|
|
650
|
+
rescue StandardError
|
|
651
|
+
# ESRCH etc. — probe already gone
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
drainer&.kill if drainer&.alive?
|
|
655
|
+
[stdout, stderr].each do |io|
|
|
656
|
+
io&.close
|
|
657
|
+
rescue StandardError
|
|
658
|
+
# already closed
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
528
662
|
# Append a stderr line to the recent-stderr ring, dropping the oldest
|
|
529
663
|
# entry once the buffer exceeds RECENT_STDERR_LINES_LIMIT. Used to surface the
|
|
530
664
|
# last few lines in ProcessError when the CLI exits non-zero.
|
|
@@ -15,6 +15,17 @@ module ClaudeAgentSDK
|
|
|
15
15
|
|
|
16
16
|
# Assert the 15 SessionStore behavioral contracts against an adapter.
|
|
17
17
|
#
|
|
18
|
+
# Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
|
|
19
|
+
# Contract 15 is a Ruby SDK extension locking empty-subpath delete
|
|
20
|
+
# coherence ('' == no subpath, the same addressing append/load already
|
|
21
|
+
# use in every implementation in both SDKs); it runs only for stores
|
|
22
|
+
# implementing #delete, and skip_optional: %w[delete] excludes the
|
|
23
|
+
# delete contracts wholesale. Note: a store ported 1:1 from Python's
|
|
24
|
+
# reference patterns that gates its delete cascade on
|
|
25
|
+
# `subpath is not None` will fail contract 15 — that pattern orphans
|
|
26
|
+
# subkeys (a known upstream incoherence; Python's own postgres example
|
|
27
|
+
# passes while its redis/s3 examples fail).
|
|
28
|
+
#
|
|
18
29
|
# Framework-agnostic: raises ConformanceError on the first violated
|
|
19
30
|
# contract, otherwise returns nil. Call it from any test framework, e.g.
|
|
20
31
|
#
|
|
@@ -205,7 +216,10 @@ module ClaudeAgentSDK
|
|
|
205
216
|
[key, sub1, sub2].each { |k| store.append(k, [entry('n' => 1)]) }
|
|
206
217
|
store.delete(key.merge('subpath' => ''))
|
|
207
218
|
assert(store.load(key).nil?, 'delete with empty subpath must remove the main transcript')
|
|
208
|
-
assert(store.load(sub1).nil?,
|
|
219
|
+
assert(store.load(sub1).nil?,
|
|
220
|
+
'delete with empty subpath must cascade to subkeys ' \
|
|
221
|
+
'(gate the cascade on sub.nil? || sub.empty? — a nil?-only gate, ' \
|
|
222
|
+
'the direct port of Python\'s `is not None`, orphans subkeys)')
|
|
209
223
|
if has_list_sessions
|
|
210
224
|
listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
|
|
211
225
|
assert(!listed.include?(key['session_id']), 'session deleted via empty subpath must not be listed')
|