claude-agent-sdk 0.19.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbcc1484868ae2ed502513727fd7df53192fc0177eed594433ad7c0332b3287a
4
- data.tar.gz: b052d3f7993aa1f4d5a2b4fc3bdb01f7cf974f939c67e82a7bac173cb0b65a8f
3
+ metadata.gz: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
4
+ data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
5
5
  SHA512:
6
- metadata.gz: b50d94d3879214f2bed9c42e4fe20a989c84e5de26a36a907c1b1093dc344a66a6b37e53a3d60a997f92ce84b4795871d55da32056a235a70617e4dd0cf85552
7
- data.tar.gz: 7899caf95b4c5da6e5f4e004ae57456e19e18b6d2be35469e88bd14f1333be4d3053fa8138dd037616c9a15ce2a9985164d065bbdb0026af90b759b31646cddb
6
+ metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
7
+ data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ 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
+
28
+ ## [0.19.1] - 2026-07-03
29
+
30
+ Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
31
+
32
+ ### Added
33
+ - `lib/claude-agent-sdk.rb` require shim: `Bundler.require` now loads the SDK in default Rails/Bundler apps. Previously Bundler tried `claude-agent-sdk` and `claude/agent/sdk`, silently swallowed both LoadErrors, and left `ClaudeAgentSDK` undefined until a confusing `NameError` at first use.
34
+
35
+ ### Fixed
36
+ - `require 'claude_agent_sdk/instrumentation'` now loads the SDK core, so the documented Rails observability initializer (and `OTelObserver`'s own `@example`) works as the only require.
37
+ - Typed `McpStdioServerConfig` / `McpSSEServerConfig` / `McpHttpServerConfig` / `McpSdkServerConfig` objects passed directly in `mcp_servers` (without `.to_h`) now serialize to their wire hash instead of a `#<...>` string the CLI cannot parse — and `McpSdkServerConfig` instances now actually register their in-process server, so its tools connect.
38
+ - `create_tool` with a Symbol name produced a tool that was advertised in `tools/list` but failed every invocation with 'Tool not found'; names are now coerced to String.
39
+ - One stdout line carrying invalid UTF-8 bytes no longer aborts the whole message stream (which dropped all buffered valid frames, including a trailing `result`); the bad line is scrubbed, matching the version-probe path.
40
+ - `fork_session` no longer hangs forever on a transcript with a `parentUuid` cycle among progress entries.
41
+ - `fork_session` / `delete_session` no longer stop at a 0-byte transcript stub: they fall through to the worktree project dir holding the real transcript, mirroring the read path. A session whose *only* copy is a 0-byte stub now raises `Errno::ENOENT`, consistent with the read paths that already hide it.
42
+ - Long-lived `Client`s using SDK MCP servers no longer leak one finished internal task per synchronously-handled control request (MCP metadata requests, unsupported subtypes).
43
+ - An observer implementing none of the observer interface — most commonly a Class passed instead of an instance (`observers: [OTelObserver]`) — is now warned about and skipped instead of producing silent zero instrumentation.
44
+
45
+ ### Changed
46
+ - The gem packages git-tracked files only (previously a working-tree glob, which shipped stray untracked files under `lib/`/`docs/` in locally built gems). `gem build` from a tree with tracked-but-deleted files now fails fast instead of silently packaging whatever was on disk.
47
+
10
48
  ## [0.19.0] - 2026-06-29
11
49
 
12
50
  ### Added
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.19.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`.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler autorequire shim. The gem is named `claude-agent-sdk`, so
4
+ # `Bundler.require` tries `require 'claude-agent-sdk'` and then
5
+ # `require 'claude/agent/sdk'` — and silently swallows both LoadErrors when
6
+ # neither file exists, leaving the SDK unloaded in default Rails/Bundler
7
+ # apps. This file makes the gem-named require resolve to the real entry point.
8
+ require_relative '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 || @options.sandbox
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 && @options.sandbox
183
- sandbox_hash = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
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
@@ -306,6 +315,9 @@ module ClaudeAgentSDK
306
315
  if @options.mcp_servers.is_a?(Hash)
307
316
  servers_for_cli = {}
308
317
  @options.mcp_servers.each do |name, config|
318
+ # Typed Mcp*ServerConfig objects serialize via their wire hash —
319
+ # without this they'd JSON-stringify as "#<...>" via to_s.
320
+ config = config.to_h if config.is_a?(Type)
309
321
  servers_for_cli[name] = if config.is_a?(Hash) && config[:type] == "sdk"
310
322
  config.except(:instance)
311
323
  else
@@ -3,4 +3,10 @@
3
3
  # Instrumentation adapters for ClaudeAgentSDK.
4
4
  # Each adapter lazy-requires its external gem, so loading this file has zero cost
5
5
  # unless you instantiate a specific observer.
6
+ #
7
+ # This entry point is documented as a standalone require (docs/rails.md's
8
+ # initializer, OTelObserver's @example), so it must load the SDK core too —
9
+ # otherwise ClaudeAgentSDK exists (observer.rb reopens the module) but has
10
+ # no .configure/.query/ClaudeAgentOptions.
11
+ require_relative '../claude_agent_sdk'
6
12
  require_relative 'instrumentation/otel'
@@ -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
- content = data.dig(:message, :content)
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: data.dig(:message, :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: data.dig(:message, :usage),
75
- message_id: data.dig(:message, :id),
76
- stop_reason: data.dig(:message, :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
  )
@@ -256,7 +256,11 @@ module ClaudeAgentSDK
256
256
  @inflight_control_request_tasks.delete(request_id) if request_id
257
257
  end
258
258
  end
259
- @inflight_control_request_tasks[request_id] = handler_task if request_id
259
+ # A handler that never suspends (MCP metadata, unsupported-subtype
260
+ # error path) already ran to completion inside the async{} above —
261
+ # its ensure-delete fired before this insert, so registering it here
262
+ # would leak a finished task in the map forever.
263
+ @inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
260
264
  when 'control_cancel_request'
261
265
  request_id = message[:request_id] || message[:requestId]
262
266
  task = request_id ? @inflight_control_request_tasks[request_id] : nil
@@ -1135,7 +1139,11 @@ module ClaudeAgentSDK
1135
1139
  def receive_messages(&block)
1136
1140
  return enum_for(:receive_messages) unless block
1137
1141
 
1138
- loop do
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
1139
1147
  message = @message_queue.dequeue
1140
1148
  break if message[:type] == 'end'
1141
1149
  raise message[:error] if message[:type] == 'error'
@@ -470,6 +470,11 @@ module ClaudeAgentSDK
470
470
  resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars
471
471
  end
472
472
 
473
+ # tools/call arrives from the CLI with the name as a JSON String; the mcp
474
+ # gem keys its lookup on the value stored here, so a Symbol name would be
475
+ # advertised in tools/list yet miss on every invocation ('Tool not found').
476
+ name = name.to_s if name.is_a?(Symbol)
477
+
473
478
  SdkMcpTool.new(
474
479
  name: name,
475
480
  description: description,
@@ -280,11 +280,17 @@ module ClaudeAgentSDK
280
280
  nil
281
281
  end
282
282
 
283
+ # A candidate counts only when it exists AND is non-empty — a 0-byte stub
284
+ # in one project dir must not stop the search when the real transcript
285
+ # lives under another (worktree) project dir. Mirrors the read path
286
+ # (Sessions.stat_candidate) and the append path (try_append).
283
287
  def try_project_dir(file_name, project_dir)
284
288
  return nil unless project_dir
285
289
 
286
290
  candidate = File.join(project_dir, file_name)
287
- File.exist?(candidate) ? [candidate, project_dir] : nil
291
+ File.size(candidate).positive? ? [candidate, project_dir] : nil
292
+ rescue SystemCallError
293
+ nil
288
294
  end
289
295
 
290
296
  def find_in_all_projects(file_name)
@@ -295,8 +301,8 @@ module ClaudeAgentSDK
295
301
  pd = File.join(projects_dir, child)
296
302
  next unless File.directory?(pd)
297
303
 
298
- candidate = File.join(pd, file_name)
299
- return [candidate, pd] if File.exist?(candidate)
304
+ result = try_project_dir(file_name, pd)
305
+ return result if result
300
306
  end
301
307
  nil
302
308
  end
@@ -523,9 +529,13 @@ module ClaudeAgentSDK
523
529
  JSON.generate(forked)
524
530
  end
525
531
 
526
- # Walk up parentUuid chain skipping progress entries.
532
+ # Walk up parentUuid chain skipping progress entries. The visited set
533
+ # guards against parentUuid cycles among progress entries (corrupt
534
+ # transcripts) like every other chain walker — an unguarded walk spun
535
+ # forever and hung both fork paths.
527
536
  def resolve_parent_uuid(parent_id, by_uuid, uuid_mapping)
528
- while parent_id
537
+ visited = Set.new
538
+ while parent_id && visited.add?(parent_id)
529
539
  parent = by_uuid[parent_id]
530
540
  break unless parent
531
541
  return uuid_mapping[parent_id] if parent['type'] != 'progress'
@@ -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'] || 0) }.each do |cand|
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
- content = entry.dig('message', 'content')
251
- next unless content
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
- custom_title = extract_json_string_field(tail, 'customTitle', last: true) ||
315
- extract_json_string_field(head, 'customTitle', last: true) ||
316
- extract_json_string_field(tail, 'aiTitle', last: true) ||
317
- extract_json_string_field(head, 'aiTitle', last: true)
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,
@@ -494,6 +494,13 @@ module ClaudeAgentSDK
494
494
  # an oversized line was fully allocated BEFORE the 1MB cap could
495
495
  # fire — unbounded memory on hostile/buggy stdout.
496
496
  @stdout.each_line("\n", @max_buffer_size + 1) do |line|
497
+ # stdout is UTF-8-tagged but the CLI can emit invalid bytes (echoed
498
+ # binary/latin-1 output). strip/lstrip below raise on invalid
499
+ # encoding, which would abort the whole stream and drop buffered
500
+ # valid frames — scrub the one bad line instead (the version-probe
501
+ # path guards the same way).
502
+ line = line.scrub unless line.valid_encoding?
503
+
497
504
  # Position-aware whitespace handling: a chunk of an over-limit line
498
505
  # must keep its interior whitespace — a blanket per-chunk strip
499
506
  # deleted spaces inside JSON strings straddling the chunk boundary
@@ -549,9 +556,14 @@ module ClaudeAgentSDK
549
556
  # concurrently and reset it) or already waited on (Errno::ECHILD on
550
557
  # double-wait). Both are non-fatal — the message loop just exits.
551
558
  returncode = nil
559
+ termsig = nil
552
560
  begin
553
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.
554
565
  returncode = status&.exitstatus
566
+ termsig = status.termsig if status&.signaled?
555
567
  rescue Errno::ECHILD
556
568
  # Process was already reaped (e.g., by close()); no exit status to surface.
557
569
  returncode = nil
@@ -564,7 +576,7 @@ module ClaudeAgentSDK
564
576
  # #close still sees @process (left set here) for its termination logic.
565
577
  self.class.deregister_active_process(@process)
566
578
 
567
- if returncode && returncode != 0
579
+ if termsig || (returncode && returncode != 0)
568
580
  # Wait briefly for stderr thread to finish draining
569
581
  @stderr_task&.join(1)
570
582
 
@@ -572,8 +584,9 @@ module ClaudeAgentSDK
572
584
  stderr_text = 'No stderr output captured' if stderr_text.empty?
573
585
 
574
586
  @exit_error = ProcessError.new(
575
- "Command failed with exit code #{returncode}",
576
- exit_code: returncode,
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,
577
590
  stderr: stderr_text
578
591
  )
579
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 then value.to_h { |k, v| [k, deep_dup_containers(v)] }
1693
- when Array then value.map { |v| deep_dup_containers(v) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.19.0'
4
+ VERSION = '0.20.0'
5
5
  end
@@ -23,13 +23,60 @@ require 'securerandom'
23
23
 
24
24
  # Claude Agent SDK for Ruby
25
25
  module ClaudeAgentSDK
26
+ # The duck-typed observer surface probed by resolve_observers — implementing
27
+ # any one of these counts as an observer (see Observer's no-op defaults).
28
+ OBSERVER_INTERFACE = %i[on_user_prompt on_message on_error on_close].freeze
29
+
26
30
  # Resolve observers array: callables (Proc/lambda) are invoked to produce
27
31
  # a fresh instance per query/session (thread-safe); plain objects are used as-is.
28
32
  # Array() guards against nil (e.g., when observers: nil is passed explicitly).
33
+ # Anything implementing none of the observer methods is warned about and
34
+ # skipped — most commonly a Class passed instead of an instance, which
35
+ # previously produced silent zero instrumentation (every notify raised
36
+ # NoMethodError, swallowed by notify_observers' error containment).
29
37
  def self.resolve_observers(observers)
30
- Array(observers).map do |obs|
31
- obs.respond_to?(:call) ? obs.call : obs
38
+ Array(observers).filter_map do |obs|
39
+ resolved = obs.respond_to?(:call) ? obs.call : obs
40
+ if OBSERVER_INTERFACE.none? { |m| resolved.respond_to?(m) }
41
+ label = resolved.is_a?(Module) ? resolved : resolved.class
42
+ hint = resolved.is_a?(Module) ? " — pass an instance (#{resolved}.new) or a factory lambda" : ''
43
+ warn "ClaudeAgentSDK: ignoring observer #{label}: it implements none of #{OBSERVER_INTERFACE.join('/')}#{hint}"
44
+ next nil
45
+ end
46
+ resolved
47
+ end
48
+ end
49
+
50
+ # Internal: pull live SDK MCP server instances out of an mcp_servers Hash.
51
+ # Accepts both raw Hash configs and typed Mcp*ServerConfig objects — a
52
+ # McpSdkServerConfig passed without .to_h previously failed the Hash-only
53
+ # guard, so its in-process server was silently never registered.
54
+ def self.extract_sdk_mcp_servers(mcp_servers)
55
+ return {} unless mcp_servers.is_a?(Hash)
56
+
57
+ servers = {}
58
+ mcp_servers.each do |name, config|
59
+ config = config.to_h if config.is_a?(Type)
60
+ servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
32
61
  end
62
+ servers
63
+ end
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
33
80
  end
34
81
 
35
82
  # Safely call a method on each observer, suppressing any errors.
@@ -341,6 +388,13 @@ module ClaudeAgentSDK
341
388
  # puts message
342
389
  # end
343
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
+
344
398
  return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
345
399
 
346
400
  options ||= ClaudeAgentOptions.new
@@ -391,12 +445,7 @@ module ClaudeAgentSDK
391
445
  transport.connect
392
446
 
393
447
  # Extract SDK MCP servers
394
- sdk_mcp_servers = {}
395
- if configured_options.mcp_servers.is_a?(Hash)
396
- configured_options.mcp_servers.each do |name, config|
397
- sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
398
- end
399
- end
448
+ sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
400
449
 
401
450
  hooks = nil
402
451
  if configured_options.hooks
@@ -426,6 +475,7 @@ module ClaudeAgentSDK
426
475
  hooks: hooks,
427
476
  agents: configured_options.agents,
428
477
  sdk_mcp_servers: sdk_mcp_servers,
478
+ exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
429
479
  skills: configured_options.skills
430
480
  )
431
481
 
@@ -903,19 +953,14 @@ module ClaudeAgentSDK
903
953
  @transport.connect
904
954
 
905
955
  # Extract SDK MCP servers
906
- sdk_mcp_servers = {}
907
- if configured_options.mcp_servers.is_a?(Hash)
908
- configured_options.mcp_servers.each do |name, config|
909
- sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
910
- end
911
- end
956
+ sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
912
957
 
913
958
  # Convert hooks to internal format
914
959
  hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
915
960
 
916
961
  # Extract exclude_dynamic_sections from preset system prompt for the
917
962
  # initialize request (older CLIs ignore unknown initialize fields)
918
- exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
963
+ exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
919
964
 
920
965
  # Create Query handler
921
966
  @query_handler = Query.new(
@@ -1038,20 +1083,6 @@ module ClaudeAgentSDK
1038
1083
  internal_hooks
1039
1084
  end
1040
1085
 
1041
- def extract_exclude_dynamic_sections(system_prompt)
1042
- if system_prompt.is_a?(SystemPromptPreset)
1043
- eds = system_prompt.exclude_dynamic_sections
1044
- return eds if [true, false].include?(eds)
1045
- elsif system_prompt.is_a?(Hash)
1046
- type = system_prompt[:type] || system_prompt['type']
1047
- if type == 'preset'
1048
- eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
1049
- return eds if [true, false].include?(eds)
1050
- end
1051
- end
1052
- nil
1053
- end
1054
-
1055
1086
  def writeln(string)
1056
1087
  write string.end_with?("\n") ? string : "#{string}\n"
1057
1088
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-29 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -119,6 +119,7 @@ files:
119
119
  - docs/rails.md
120
120
  - docs/sessions.md
121
121
  - docs/types.md
122
+ - lib/claude-agent-sdk.rb
122
123
  - lib/claude_agent_sdk.rb
123
124
  - lib/claude_agent_sdk/command_builder.rb
124
125
  - lib/claude_agent_sdk/configuration.rb