claude-agent-sdk 0.19.1 → 0.20.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 +18 -0
- data/README.md +1 -1
- data/lib/claude_agent_sdk/command_builder.rb +16 -7
- data/lib/claude_agent_sdk/message_parser.rb +16 -5
- data/lib/claude_agent_sdk/query.rb +5 -1
- data/lib/claude_agent_sdk/session_resume.rb +18 -2
- data/lib/claude_agent_sdk/sessions.rb +47 -17
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +9 -3
- data/lib/claude_agent_sdk/types.rb +21 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +26 -15
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
|
|
4
|
+
data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
|
|
7
|
+
data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Behavioral fix batch (Batch B) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #42). Every fix aligns code with a documented contract or Python SDK behavior and changes behavior only for inputs that previously produced wrong results, hangs, or crashes. Minor (not patch) because two fixes raise where the SDK previously reported success: a signal-killed CLI now raises `ProcessError`, and assistant messages without `message.model` now raise `MessageParseError`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- A CLI process killed by a signal (OOM-kill SIGKILL, SIGSEGV, ...) now raises `ProcessError` ("Command terminated by signal N", `exit_code: -N` — Python returncode parity) instead of reporting a **truncated** response as clean end-of-stream success.
|
|
16
|
+
- A trailing title-clearing entry (`{"customTitle":""}`, or whitespace-only) no longer silently drops the whole session from `list_sessions`/`get_session_info` disk listings: blank values now fall through the summary/title fallback chains exactly like the SessionStore path and Python (`or` semantics).
|
|
17
|
+
- `continue_conversation: true` with a SessionStore adapter that reports mtimes as Strings (the natural SQL-timestamp-through-JSON shape) no longer silently resumes the **oldest** session: String mtimes (ISO-8601 or numeric) are coerced and ordered chronologically, and mixed Integer/String lists no longer raise a bare `ArgumentError`. The conformance suite already pins the epoch-ms Numeric contract.
|
|
18
|
+
- One malformed line in a transcript head (non-Hash entry, string `message`, non-string `text`) no longer drops the whole session from disk listings — the guards ported from Python skip just the bad line. An assistant line whose tool_use input embeds `"type":"user"` can no longer donate its text as the session's first prompt.
|
|
19
|
+
- A user message block that leaks `StopIteration` (e.g. `.next` on an exhausted Enumerator) no longer silently ends message reception mid-turn — previously the `ResultMessage` was dropped and `receive_response`/`query()` returned as if the turn had completed; the error now propagates.
|
|
20
|
+
- Top-level `query()` now validates the prompt like `Client#query` and fails fast at the call site: a bare Hash (which would stream `[key, value]` garbage to the CLI) and non-String/non-`#each` prompts (which hung forever) raise `ArgumentError`.
|
|
21
|
+
- One-shot `query()` now sends `exclude_dynamic_sections` from a preset system prompt in the initialize request (it was silently dropped; `Client` and Python both send it).
|
|
22
|
+
- `ClaudeAgentOptions#dup_with` now deep-duplicates nested containers: mutating a derived variant (`variant.allowed_tools << 'Bash'`) no longer bleeds into the base options and every sibling variant — including the security-relevant allow/deny lists. Leaf objects (callbacks, SDK MCP server instances, store adapters) keep identity, mirroring `Configuration#default_options`. Container deep-dup (both here and in `Configuration#default_options` merging) now also preserves Hash/Array subclasses — a Rails `HashWithIndifferentAccess` config no longer flattens into a plain Hash whose symbol lookups silently return nil.
|
|
23
|
+
- Sandbox gating in command building now matches Python's `sandbox is not None`: `sandbox: true` (the boolean toggle) no longer crashes with `NoMethodError: undefined method 'empty?' for true`, and an explicit `sandbox: false` / `{}` is forwarded to the CLI instead of silently dropped — so `sandbox: false` can actually override a sandbox enabled in the settings JSON.
|
|
24
|
+
- `output_format: { type: 'json_schema' }` with a nil/absent schema no longer emits `--json-schema null` (which the CLI rejects at spawn); the flag is skipped, matching Python's `schema is not None` guard.
|
|
25
|
+
- A non-Hash `message` field in a user/assistant CLI message now raises the documented `MessageParseError` instead of a raw `TypeError`.
|
|
26
|
+
- Assistant messages missing `message.model` now raise `MessageParseError` (Python parity) instead of silently constructing `AssistantMessage(model: nil)`.
|
|
27
|
+
|
|
10
28
|
## [0.19.1] - 2026-07-03
|
|
11
29
|
|
|
12
30
|
Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.20.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
@@ -156,8 +156,13 @@ module ClaudeAgentSDK
|
|
|
156
156
|
cmd.push("--resume-session-at", @options.resume_session_at.to_s)
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
+
# Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
|
|
160
|
+
# Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
|
|
161
|
+
# must reach the CLI so it can override a sandbox enabled in the
|
|
162
|
+
# settings JSON (`sandbox: true`, the boolean toggle, used to crash on
|
|
163
|
+
# true.empty?; false and {} were silently dropped).
|
|
159
164
|
def append_settings(cmd)
|
|
160
|
-
return unless @options.settings ||
|
|
165
|
+
return unless @options.settings || !@options.sandbox.nil?
|
|
161
166
|
|
|
162
167
|
settings_hash = {}
|
|
163
168
|
settings_is_path = false
|
|
@@ -167,11 +172,11 @@ module ClaudeAgentSDK
|
|
|
167
172
|
begin
|
|
168
173
|
settings_hash = JSON.parse(@options.settings)
|
|
169
174
|
rescue JSON::ParserError
|
|
170
|
-
if @options.sandbox
|
|
171
|
-
settings_hash = load_settings_file(@options.settings)
|
|
172
|
-
else
|
|
175
|
+
if @options.sandbox.nil?
|
|
173
176
|
settings_is_path = true
|
|
174
177
|
cmd.push("--settings", @options.settings)
|
|
178
|
+
else
|
|
179
|
+
settings_hash = load_settings_file(@options.settings)
|
|
175
180
|
end
|
|
176
181
|
end
|
|
177
182
|
elsif @options.settings.is_a?(Hash)
|
|
@@ -179,9 +184,8 @@ module ClaudeAgentSDK
|
|
|
179
184
|
end
|
|
180
185
|
end
|
|
181
186
|
|
|
182
|
-
if !settings_is_path &&
|
|
183
|
-
|
|
184
|
-
settings_hash[:sandbox] = sandbox_hash unless sandbox_hash.empty?
|
|
187
|
+
if !settings_is_path && !@options.sandbox.nil?
|
|
188
|
+
settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
|
|
185
189
|
end
|
|
186
190
|
|
|
187
191
|
cmd.push("--settings", JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
|
|
@@ -292,6 +296,11 @@ module ClaudeAgentSDK
|
|
|
292
296
|
else
|
|
293
297
|
@options.output_format
|
|
294
298
|
end
|
|
299
|
+
# A json_schema output_format with a nil/absent schema must skip the
|
|
300
|
+
# flag — `--json-schema null` is rejected by the CLI (Python guards
|
|
301
|
+
# `schema is not None`).
|
|
302
|
+
return if schema.nil?
|
|
303
|
+
|
|
295
304
|
schema_json = schema.is_a?(String) ? schema : JSON.generate(schema)
|
|
296
305
|
cmd.push("--json-schema", schema_json)
|
|
297
306
|
end
|
|
@@ -46,6 +46,9 @@ module ClaudeAgentSDK
|
|
|
46
46
|
tool_use_result = data[:tool_use_result]
|
|
47
47
|
message_data = data[:message]
|
|
48
48
|
raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
|
|
49
|
+
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
50
|
+
# message_data[:content] instead of the documented MessageParseError.
|
|
51
|
+
raise MessageParseError.new("Invalid message field in user message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
|
|
49
52
|
|
|
50
53
|
content = message_data[:content]
|
|
51
54
|
raise MessageParseError.new("Missing content in user message", data: data) unless content
|
|
@@ -61,19 +64,27 @@ module ClaudeAgentSDK
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def self.parse_assistant_message(data)
|
|
64
|
-
|
|
67
|
+
message_data = data[:message]
|
|
68
|
+
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
69
|
+
# dig instead of the documented MessageParseError.
|
|
70
|
+
raise MessageParseError.new("Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
content = message_data[:content]
|
|
65
73
|
raise MessageParseError.new("Missing content in assistant message", data: data) unless content
|
|
66
74
|
raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
|
|
67
75
|
|
|
68
76
|
content_blocks = parse_content_blocks(content, data)
|
|
69
77
|
AssistantMessage.new(
|
|
70
78
|
content: content_blocks,
|
|
71
|
-
model
|
|
79
|
+
# model is required, like Python — fetch raises KeyError when absent,
|
|
80
|
+
# which parse's rescue wraps into MessageParseError, instead of
|
|
81
|
+
# silently constructing model: nil.
|
|
82
|
+
model: message_data.fetch(:model),
|
|
72
83
|
parent_tool_use_id: data[:parent_tool_use_id],
|
|
73
84
|
error: data[:error], # authentication_failed, billing_error, rate_limit, invalid_request, server_error, unknown
|
|
74
|
-
usage:
|
|
75
|
-
message_id:
|
|
76
|
-
stop_reason:
|
|
85
|
+
usage: message_data[:usage],
|
|
86
|
+
message_id: message_data[:id],
|
|
87
|
+
stop_reason: message_data[:stop_reason],
|
|
77
88
|
session_id: data[:session_id],
|
|
78
89
|
uuid: data[:uuid]
|
|
79
90
|
)
|
|
@@ -1139,7 +1139,11 @@ module ClaudeAgentSDK
|
|
|
1139
1139
|
def receive_messages(&block)
|
|
1140
1140
|
return enum_for(:receive_messages) unless block
|
|
1141
1141
|
|
|
1142
|
-
loop
|
|
1142
|
+
# NOT Kernel#loop: it rescues StopIteration, so a user block leaking one
|
|
1143
|
+
# (e.g. calling .next on an exhausted Enumerator) would silently end
|
|
1144
|
+
# reception — ResultMessage dropped, the query reported as complete, and
|
|
1145
|
+
# on_error never fired. `while` propagates it like any other error.
|
|
1146
|
+
while true # rubocop:disable Style/InfiniteLoop
|
|
1143
1147
|
message = @message_queue.dequeue
|
|
1144
1148
|
break if message[:type] == 'end'
|
|
1145
1149
|
raise message[:error] if message[:type] == 'error'
|
|
@@ -151,7 +151,7 @@ module ClaudeAgentSDK
|
|
|
151
151
|
end
|
|
152
152
|
return nil if sessions.nil? || sessions.empty?
|
|
153
153
|
|
|
154
|
-
sessions.sort_by { |s| -(s['mtime']
|
|
154
|
+
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
155
155
|
sid = cand['session_id']
|
|
156
156
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
157
157
|
|
|
@@ -166,6 +166,22 @@ module ClaudeAgentSDK
|
|
|
166
166
|
nil
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
+
# Adapters contractually report mtime as an epoch-ms Numeric (the
|
|
170
|
+
# conformance suite asserts it), but SQL timestamps naturally arrive as
|
|
171
|
+
# ISO-8601 Strings through JSON. Unary minus on a String is String#-@
|
|
172
|
+
# (frozen-string dedup), so String mtimes sorted lexicographically
|
|
173
|
+
# ASCENDING — --continue silently resumed the OLDEST session — and mixed
|
|
174
|
+
# Integer/String lists raised a bare ArgumentError. Coerce defensively:
|
|
175
|
+
# numeric strings and ISO-8601 both order correctly; anything else sorts
|
|
176
|
+
# last rather than crashing resume.
|
|
177
|
+
def sortable_mtime(value)
|
|
178
|
+
case value
|
|
179
|
+
when Numeric then value
|
|
180
|
+
when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
|
|
181
|
+
else 0
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
169
185
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
170
186
|
# re-raising failures/timeouts as RuntimeError with context. The thread hop
|
|
171
187
|
# (FiberBoundary with a timeout always hops) both keeps the async scheduler
|
|
@@ -436,7 +452,7 @@ module ClaudeAgentSDK
|
|
|
436
452
|
value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
|
|
437
453
|
end
|
|
438
454
|
|
|
439
|
-
private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
|
|
455
|
+
private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
|
|
440
456
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
441
457
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
442
458
|
:resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
|
|
@@ -236,6 +236,19 @@ module ClaudeAgentSDK
|
|
|
236
236
|
str
|
|
237
237
|
end
|
|
238
238
|
|
|
239
|
+
# Python's `x or None` for the summary/title fallback chains: Ruby's ||
|
|
240
|
+
# treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
|
|
241
|
+
# would win over a real first prompt and then fail the summary presence
|
|
242
|
+
# check — silently dropping the whole session from disk listings (the
|
|
243
|
+
# store path, SessionSummary.presence, already falls through correctly).
|
|
244
|
+
# Whitespace-only counts as blank because the final gate strips.
|
|
245
|
+
def presence(val)
|
|
246
|
+
return nil if val.nil?
|
|
247
|
+
return nil if val.is_a?(String) && val.strip.empty?
|
|
248
|
+
|
|
249
|
+
val
|
|
250
|
+
end
|
|
251
|
+
|
|
239
252
|
# Extract the first meaningful user prompt from the head of a JSONL file
|
|
240
253
|
def extract_first_prompt_from_head(head)
|
|
241
254
|
command_fallback = nil
|
|
@@ -247,16 +260,8 @@ module ClaudeAgentSDK
|
|
|
247
260
|
next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
|
|
248
261
|
|
|
249
262
|
entry = JSON.parse(line, symbolize_names: false)
|
|
250
|
-
|
|
251
|
-
next unless
|
|
252
|
-
|
|
253
|
-
texts = if content.is_a?(String)
|
|
254
|
-
[content]
|
|
255
|
-
elsif content.is_a?(Array)
|
|
256
|
-
content.filter_map { |block| block['text'] if block.is_a?(Hash) && block['type'] == 'text' }
|
|
257
|
-
else
|
|
258
|
-
next
|
|
259
|
-
end
|
|
263
|
+
texts = user_entry_texts(entry)
|
|
264
|
+
next unless texts
|
|
260
265
|
|
|
261
266
|
texts.each do |text|
|
|
262
267
|
text = text.gsub(/\n+/, ' ').strip
|
|
@@ -278,6 +283,29 @@ module ClaudeAgentSDK
|
|
|
278
283
|
command_fallback || ''
|
|
279
284
|
end
|
|
280
285
|
|
|
286
|
+
# Text blocks of a genuine user entry, or nil when the line should be
|
|
287
|
+
# skipped. Shape guards ported from Python: the byte pre-filter can match
|
|
288
|
+
# `"type":"user"` nested inside a tool_use input on an assistant line, so
|
|
289
|
+
# the parsed type is rechecked; and a malformed head line (non-Hash entry,
|
|
290
|
+
# string `message`, non-string `text`) must skip just that line rather
|
|
291
|
+
# than blow up into read_session_lite's blanket rescue and silently drop
|
|
292
|
+
# the whole session from disk listings.
|
|
293
|
+
def user_entry_texts(entry)
|
|
294
|
+
return nil unless entry.is_a?(Hash) && entry['type'] == 'user'
|
|
295
|
+
|
|
296
|
+
message = entry['message']
|
|
297
|
+
return nil unless message.is_a?(Hash)
|
|
298
|
+
|
|
299
|
+
content = message['content']
|
|
300
|
+
if content.is_a?(String)
|
|
301
|
+
[content]
|
|
302
|
+
elsif content.is_a?(Array)
|
|
303
|
+
content.filter_map do |block|
|
|
304
|
+
block['text'] if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
281
309
|
# Read a single session file with lite (head/tail) strategy
|
|
282
310
|
def read_session_lite(file_path, project_path)
|
|
283
311
|
stat = File.stat(file_path)
|
|
@@ -311,15 +339,17 @@ module ClaudeAgentSDK
|
|
|
311
339
|
def build_session_info(file_path, head, tail, stat, project_path)
|
|
312
340
|
# User-set title (customTitle) wins over AI-generated title (aiTitle).
|
|
313
341
|
# Head fallback covers short sessions where the title entry may not be in tail.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
extract_json_string_field(head, '
|
|
342
|
+
# Each candidate passes through presence so a blank value (e.g. a
|
|
343
|
+
# trailing title-clearing entry) falls through instead of short-circuiting.
|
|
344
|
+
custom_title = presence(extract_json_string_field(tail, 'customTitle', last: true)) ||
|
|
345
|
+
presence(extract_json_string_field(head, 'customTitle', last: true)) ||
|
|
346
|
+
presence(extract_json_string_field(tail, 'aiTitle', last: true)) ||
|
|
347
|
+
presence(extract_json_string_field(head, 'aiTitle', last: true))
|
|
318
348
|
first_prompt = extract_first_prompt_from_head(head)
|
|
319
349
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
320
350
|
summary = custom_title ||
|
|
321
|
-
extract_json_string_field(tail, 'lastPrompt', last: true) ||
|
|
322
|
-
extract_json_string_field(tail, 'summary', last: true) ||
|
|
351
|
+
presence(extract_json_string_field(tail, 'lastPrompt', last: true)) ||
|
|
352
|
+
presence(extract_json_string_field(tail, 'summary', last: true)) ||
|
|
323
353
|
first_prompt
|
|
324
354
|
return nil if summary.nil? || summary.strip.empty?
|
|
325
355
|
|
|
@@ -1235,7 +1265,7 @@ module ClaudeAgentSDK
|
|
|
1235
1265
|
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1236
1266
|
:collect_agent_files, :parse_jsonl_entries,
|
|
1237
1267
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1238
|
-
:filter_visible_messages, :read_head_tail, :build_session_info,
|
|
1268
|
+
:filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
|
|
1239
1269
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
1240
1270
|
:derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
|
|
1241
1271
|
:filter_transcript_entries, :entries_to_messages,
|
|
@@ -556,9 +556,14 @@ module ClaudeAgentSDK
|
|
|
556
556
|
# concurrently and reset it) or already waited on (Errno::ECHILD on
|
|
557
557
|
# double-wait). Both are non-fatal — the message loop just exits.
|
|
558
558
|
returncode = nil
|
|
559
|
+
termsig = nil
|
|
559
560
|
begin
|
|
560
561
|
status = @process&.value
|
|
562
|
+
# exitstatus is nil when the child died from a signal (OOM-kill
|
|
563
|
+
# SIGKILL, SIGSEGV, ...) — that end-of-stream is a TRUNCATED response,
|
|
564
|
+
# not a clean success. Python surfaces it as a negative returncode.
|
|
561
565
|
returncode = status&.exitstatus
|
|
566
|
+
termsig = status.termsig if status&.signaled?
|
|
562
567
|
rescue Errno::ECHILD
|
|
563
568
|
# Process was already reaped (e.g., by close()); no exit status to surface.
|
|
564
569
|
returncode = nil
|
|
@@ -571,7 +576,7 @@ module ClaudeAgentSDK
|
|
|
571
576
|
# #close still sees @process (left set here) for its termination logic.
|
|
572
577
|
self.class.deregister_active_process(@process)
|
|
573
578
|
|
|
574
|
-
if returncode && returncode != 0
|
|
579
|
+
if termsig || (returncode && returncode != 0)
|
|
575
580
|
# Wait briefly for stderr thread to finish draining
|
|
576
581
|
@stderr_task&.join(1)
|
|
577
582
|
|
|
@@ -579,8 +584,9 @@ module ClaudeAgentSDK
|
|
|
579
584
|
stderr_text = 'No stderr output captured' if stderr_text.empty?
|
|
580
585
|
|
|
581
586
|
@exit_error = ProcessError.new(
|
|
582
|
-
"Command failed with exit code #{returncode}",
|
|
583
|
-
exit_code
|
|
587
|
+
termsig ? "Command terminated by signal #{termsig}" : "Command failed with exit code #{returncode}",
|
|
588
|
+
# Negative-signal exit_code mirrors Python's subprocess returncode.
|
|
589
|
+
exit_code: termsig ? -termsig : returncode,
|
|
584
590
|
stderr: stderr_text
|
|
585
591
|
)
|
|
586
592
|
raise @exit_error
|
|
@@ -1586,6 +1586,15 @@ module ClaudeAgentSDK
|
|
|
1586
1586
|
|
|
1587
1587
|
def dup_with(**changes)
|
|
1588
1588
|
new_options = self.dup
|
|
1589
|
+
# A shallow #dup shares nested containers, so mutating a derived copy
|
|
1590
|
+
# (e.g. `variant.allowed_tools << 'Bash'`) would bleed into the base and
|
|
1591
|
+
# every sibling — including the security-relevant allow/deny lists.
|
|
1592
|
+
# Deep-dup Hash/Array containers only; non-container values (procs,
|
|
1593
|
+
# SDK MCP server instances, store adapters) must keep their identity.
|
|
1594
|
+
new_options.instance_variables.each do |ivar|
|
|
1595
|
+
value = new_options.instance_variable_get(ivar)
|
|
1596
|
+
new_options.instance_variable_set(ivar, deep_dup_containers(value)) if value.is_a?(Hash) || value.is_a?(Array)
|
|
1597
|
+
end
|
|
1589
1598
|
changes.each { |key, value| new_options[key] = value }
|
|
1590
1599
|
new_options
|
|
1591
1600
|
end
|
|
@@ -1687,10 +1696,20 @@ module ClaudeAgentSDK
|
|
|
1687
1696
|
|
|
1688
1697
|
# Recurse ONLY into Hash/Array; leaves keep object identity (observer
|
|
1689
1698
|
# factories, callbacks, SDK MCP server instances must not be duped).
|
|
1699
|
+
# Rebuild via dup.clear (never Hash#to_h / Array#map) to preserve
|
|
1700
|
+
# container SUBCLASSES: to_h flattens e.g. Rails'
|
|
1701
|
+
# HashWithIndifferentAccess into a plain Hash, silently breaking symbol
|
|
1702
|
+
# lookups on the copy (config[:type] == 'sdk' → nil).
|
|
1690
1703
|
def deep_dup_containers(value)
|
|
1691
1704
|
case value
|
|
1692
|
-
when Hash
|
|
1693
|
-
|
|
1705
|
+
when Hash
|
|
1706
|
+
copy = value.dup.clear
|
|
1707
|
+
value.each { |k, v| copy[k] = deep_dup_containers(v) }
|
|
1708
|
+
copy
|
|
1709
|
+
when Array
|
|
1710
|
+
copy = value.dup.clear
|
|
1711
|
+
value.each { |v| copy << deep_dup_containers(v) }
|
|
1712
|
+
copy
|
|
1694
1713
|
else value
|
|
1695
1714
|
end
|
|
1696
1715
|
end
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -62,6 +62,23 @@ module ClaudeAgentSDK
|
|
|
62
62
|
servers
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Internal: pull exclude_dynamic_sections out of a preset system prompt for
|
|
66
|
+
# the initialize request (older CLIs ignore unknown initialize fields).
|
|
67
|
+
# Shared by Client#connect and the one-shot query() path.
|
|
68
|
+
def self.extract_exclude_dynamic_sections(system_prompt)
|
|
69
|
+
if system_prompt.is_a?(SystemPromptPreset)
|
|
70
|
+
eds = system_prompt.exclude_dynamic_sections
|
|
71
|
+
return eds if [true, false].include?(eds)
|
|
72
|
+
elsif system_prompt.is_a?(Hash)
|
|
73
|
+
type = system_prompt[:type] || system_prompt['type']
|
|
74
|
+
if type == 'preset'
|
|
75
|
+
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
|
|
76
|
+
return eds if [true, false].include?(eds)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
65
82
|
# Safely call a method on each observer, suppressing any errors.
|
|
66
83
|
# Each observer is invoked through FiberBoundary so that user code runs
|
|
67
84
|
# on a plain thread (no Fiber scheduler) even when called from inside
|
|
@@ -371,6 +388,13 @@ module ClaudeAgentSDK
|
|
|
371
388
|
# puts message
|
|
372
389
|
# end
|
|
373
390
|
def self.query(prompt:, options: nil, transport: nil, &block)
|
|
391
|
+
# Validate BEFORE the block-less enum_for return so a bad prompt fails at
|
|
392
|
+
# the call site, not on first iteration. Mirrors Client#query: a bare Hash
|
|
393
|
+
# responds to #each and would stream [key, value] pairs' to_s garbage to
|
|
394
|
+
# the CLI; nil/Integer would hang forever waiting for input.
|
|
395
|
+
raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
|
|
396
|
+
raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})" unless prompt.is_a?(String) || prompt.respond_to?(:each)
|
|
397
|
+
|
|
374
398
|
return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
|
|
375
399
|
|
|
376
400
|
options ||= ClaudeAgentOptions.new
|
|
@@ -451,6 +475,7 @@ module ClaudeAgentSDK
|
|
|
451
475
|
hooks: hooks,
|
|
452
476
|
agents: configured_options.agents,
|
|
453
477
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
478
|
+
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
454
479
|
skills: configured_options.skills
|
|
455
480
|
)
|
|
456
481
|
|
|
@@ -935,7 +960,7 @@ module ClaudeAgentSDK
|
|
|
935
960
|
|
|
936
961
|
# Extract exclude_dynamic_sections from preset system prompt for the
|
|
937
962
|
# initialize request (older CLIs ignore unknown initialize fields)
|
|
938
|
-
exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
963
|
+
exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
939
964
|
|
|
940
965
|
# Create Query handler
|
|
941
966
|
@query_handler = Query.new(
|
|
@@ -1058,20 +1083,6 @@ module ClaudeAgentSDK
|
|
|
1058
1083
|
internal_hooks
|
|
1059
1084
|
end
|
|
1060
1085
|
|
|
1061
|
-
def extract_exclude_dynamic_sections(system_prompt)
|
|
1062
|
-
if system_prompt.is_a?(SystemPromptPreset)
|
|
1063
|
-
eds = system_prompt.exclude_dynamic_sections
|
|
1064
|
-
return eds if [true, false].include?(eds)
|
|
1065
|
-
elsif system_prompt.is_a?(Hash)
|
|
1066
|
-
type = system_prompt[:type] || system_prompt['type']
|
|
1067
|
-
if type == 'preset'
|
|
1068
|
-
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
|
|
1069
|
-
return eds if [true, false].include?(eds)
|
|
1070
|
-
end
|
|
1071
|
-
end
|
|
1072
|
-
nil
|
|
1073
|
-
end
|
|
1074
|
-
|
|
1075
1086
|
def writeln(string)
|
|
1076
1087
|
write string.end_with?("\n") ? string : "#{string}\n"
|
|
1077
1088
|
end
|