claude-agent-sdk 0.19.1 → 0.21.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: fc9c6b243b2a7ac982141db36c55d510d9a08cb18caeffb5dd2d9ed59fefc42d
4
- data.tar.gz: 7ce8124f1cebd923857fda75532caa8332a89d930564ff9c474e847e57fcb1d8
3
+ metadata.gz: 408fe2ed3f2f1f00866e3cef0b1795e67dc50dafd3292fc016985f62c10f1b6e
4
+ data.tar.gz: 045b04fee0681072fe7e99c81ab7a99d51d2eec362baf282a45c8d825cc38135
5
5
  SHA512:
6
- metadata.gz: 7c548159588586dd908b4a51173bdf89abac417ceab753d8eb8b313095463770dbf5fc44e7af9c90dce26df651f11ae5b25eca28d755a17e2c5b68984eff1ccb
7
- data.tar.gz: 0a46ae5342425411af912c98ab5f96607bdf4d8ec76f027a7388b5519c3dc7d425a79e03a4b82c7317e9f983dcd232c7375d91f23feb33ecb758289f57dcce3b
6
+ metadata.gz: 91b04e46d137103527e3af91d089acc01be96b69a8b0e6902eecb603654567ee826c8284ac0f9b797f180af64220ecfe0d71c3ab6f5e755e03525d596fcff85e
7
+ data.tar.gz: fb7e3d0a535522e98ed3c7a0180f1ac35ca5057a29dfdb3429b07cd896375672c0c94e114d92809325680f599ca39d8dee2f7fc3a658a73fabd744cf4a987946
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] - 2026-07-03
11
+
12
+ Design-decision fix batch (Batch C) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #43), plus three teardown-race hardenings from its adversarial review. Minor (not patch) because three fixes change observable behavior for previously-broken flows: a missing settings file with sandbox no longer raises, a raising initial prompt stream no longer notifies observers, and teardown can now preserve (instead of delete) the materialized resume dir.
13
+
14
+ ### Fixed
15
+ - `Query#close` (and therefore `Client#disconnect`) is now safe to call from any thread. Calling it from a FiberBoundary worker — e.g. a tool handler, hook, or permission callback ending the session — crashed with `NoMethodError` (nil `Fiber.scheduler`) and left the read/child tasks running, hanging the enclosing reactor. A transient reactor-side watcher now serves closes marshaled from foreign threads with identical semantics; once the reactor is gone, close falls back to a direct (fiber-dead-safe) teardown. `close` is also idempotent now — concurrent or repeated closes no longer re-run teardown against half-torn-down state.
16
+ - Eager-mode mirror failures during the last flush of a stream are reported before the `'end'` sentinel: the `MirrorErrorMessage` for an in-flight dropped batch could previously be enqueued after `'end'` and never delivered, violating the documented "failures surface as MirrorErrorMessage" guarantee.
17
+ - Disk session listings no longer report tool-argument text as the session summary/title: the head/tail byte-scans for `summary`/`customTitle`/`aiTitle`/`lastPrompt` now verify each match against the top level of its containing JSONL line (the same keys the SessionStore fold reads), so keys nested inside tool_use inputs (subagent/teammate tool arguments) are ignored. Lines truncated at the 64KB window edge keep the raw-scan value.
18
+ - Resume-from-store teardown no longer deletes the only copy of turns the mirror failed to persist: when the batcher dropped batches (tracked via `TranscriptMirrorBatcher#batches_dropped?` / `Query#mirror_batches_dropped?`, including drains cancelled mid-flight), the materialized temp `CLAUDE_CONFIG_DIR` is preserved — credential copies scrubbed, transcripts kept, warning with the path — instead of removed.
19
+ - A missing settings file with `sandbox` set now warns and continues with sandbox-only settings (Python parity) instead of raising `CLIConnectionError`.
20
+ - `Client#connect` no longer fires observer `on_error` for a raising initial Enumerator prompt: input-stream errors are swallowed-with-warn (documented `Observer#on_error` contract, `query()` and Python parity). Notifying them also marked still-live OTel traces as failed.
21
+ - Postgres reference adapter (`examples/session_stores/postgres_session_store.rb`): all DB round-trips now serialize through an internal mutex — mirror appends run on fresh worker threads and can overlap after a send-timeout abandon, which pg's single-connection thread rules forbid. The "a single PG::Connection suffices" concurrency guidance was wrong and has been rewritten.
22
+
23
+ ## [0.20.0] - 2026-07-03
24
+
25
+ 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`.
26
+
27
+ ### Fixed
28
+ - 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.
29
+ - 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).
30
+ - `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.
31
+ - 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.
32
+ - 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.
33
+ - 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`.
34
+ - 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).
35
+ - `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.
36
+ - 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.
37
+ - `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.
38
+ - A non-Hash `message` field in a user/assistant CLI message now raises the documented `MessageParseError` instead of a raw `TypeError`.
39
+ - Assistant messages missing `message.model` now raise `MessageParseError` (Python parity) instead of silently constructing `AssistantMessage(model: nil)`.
40
+
10
41
  ## [0.19.1] - 2026-07-03
11
42
 
12
43
  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.19.1'
71
+ gem 'claude-agent-sdk', '~> 0.21.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
data/docs/sessions.md CHANGED
@@ -152,6 +152,12 @@ during resume materialization, default `60_000`).
152
152
  > `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
153
153
  > programmatically via `ClaudeAgentOptions` are unaffected. This matches the
154
154
  > Python and TypeScript SDKs.
155
+ >
156
+ > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
157
+ > (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
158
+ > the store copy is then incomplete and the temp dir holds the only copy of the
159
+ > dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
160
+ > and warns with the preserved path so you can import them into the store.
155
161
 
156
162
  ### Implementing an adapter
157
163
 
@@ -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
@@ -367,7 +376,14 @@ module ClaudeAgentSDK
367
376
  end
368
377
 
369
378
  def load_settings_file(path)
370
- raise CLIConnectionError, "Settings file not found: #{path}" unless File.file?(path)
379
+ # Missing file: warn and continue with sandbox-only settings (Python
380
+ # parity: logger.warning("Settings file not found: ...") and an empty
381
+ # settings object). Raising here turned a misconfiguration the CLI
382
+ # tolerates into a hard connect failure.
383
+ unless File.file?(path)
384
+ warn "Claude SDK: Settings file not found: #{path}"
385
+ return {}
386
+ end
371
387
 
372
388
  JSON.parse(File.read(path))
373
389
  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
- 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
  )
@@ -77,6 +77,16 @@ module ClaudeAgentSDK
77
77
  @closed = false
78
78
  @initialization_result = nil
79
79
  @transcript_mirror_batcher = nil
80
+
81
+ # Cross-thread close marshaling (see #close). Thread::Queue is the one
82
+ # primitive that is both fiber-scheduler-aware on the reactor side and
83
+ # thread-safe on the caller side (push -> scheduler#unblock).
84
+ @close_requests = ::Thread::Queue.new
85
+ @close_watcher = nil
86
+ @owning_scheduler = nil
87
+ # First-caller-wins guard for close_now (see there).
88
+ @close_mutex = Mutex.new
89
+ @close_started = false
80
90
  end
81
91
 
82
92
  # Initialize control protocol if in streaming mode
@@ -178,7 +188,24 @@ module ClaudeAgentSDK
178
188
  parent = Async::Task.current?
179
189
  raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
180
190
 
191
+ @owning_scheduler = Fiber.scheduler
181
192
  @task = parent.async { read_messages }
193
+ # Reactor-side agent for #close calls arriving from foreign threads
194
+ # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
195
+ # the owning thread's Fiber.scheduler, so the off-thread caller hands the
196
+ # whole close over and waits. Transient: must never keep the reactor
197
+ # alive, and is stopped automatically when the parent task finishes.
198
+ # One-shot: after serving a close it is done; a reactor-side close wakes
199
+ # it via @close_requests.close (pop -> nil) so it exits without serving.
200
+ @close_watcher = parent.async(transient: true) do
201
+ if (reply = @close_requests.pop)
202
+ begin
203
+ close
204
+ ensure
205
+ reply << true
206
+ end
207
+ end
208
+ end
182
209
  end
183
210
 
184
211
  # Spawn a child task that is stopped by #close (mirrors the Python SDK's
@@ -206,6 +233,14 @@ module ClaudeAgentSDK
206
233
  @transcript_mirror_batcher = batcher
207
234
  end
208
235
 
236
+ # True when the mirror dropped at least one batch (store copy incomplete).
237
+ # Meaningful after #close, which runs the final flush. Consulted by the
238
+ # resume-from-store teardown to decide whether the materialized temp dir
239
+ # holds the only copy of some turns and must be preserved.
240
+ def mirror_batches_dropped?
241
+ !!@transcript_mirror_batcher&.batches_dropped?
242
+ end
243
+
209
244
  # Synthesize a `mirror_error` system message and put it on the SDK message
210
245
  # stream so consumers learn a mirror batch was dropped after exhausting
211
246
  # retries. Non-blocking: the message queue is unbounded, so unlike the
@@ -1139,7 +1174,11 @@ module ClaudeAgentSDK
1139
1174
  def receive_messages(&block)
1140
1175
  return enum_for(:receive_messages) unless block
1141
1176
 
1142
- loop do
1177
+ # NOT Kernel#loop: it rescues StopIteration, so a user block leaking one
1178
+ # (e.g. calling .next on an exhausted Enumerator) would silently end
1179
+ # reception — ResultMessage dropped, the query reported as complete, and
1180
+ # on_error never fired. `while` propagates it like any other error.
1181
+ while true # rubocop:disable Style/InfiniteLoop
1143
1182
  message = @message_queue.dequeue
1144
1183
  break if message[:type] == 'end'
1145
1184
  raise message[:error] if message[:type] == 'error'
@@ -1148,8 +1187,45 @@ module ClaudeAgentSDK
1148
1187
  end
1149
1188
  end
1150
1189
 
1151
- # Close the query and transport
1190
+ # Close the query and transport.
1191
+ #
1192
+ # Callable from any thread. Async::Task#stop needs the reactor's
1193
+ # Fiber.scheduler (per-thread), which foreign callers — FiberBoundary
1194
+ # workers running a tool handler / hook that calls client.disconnect, or
1195
+ # plain user threads — don't have; stopping from one raised NoMethodError
1196
+ # and left the read/child tasks running. Such callers hand the close to
1197
+ # the reactor-side watcher (spawned in #start) and wait for it to finish,
1198
+ # so close semantics are identical regardless of the calling thread.
1152
1199
  def close
1200
+ if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
1201
+ marshal_close_to_reactor
1202
+ else
1203
+ # Same scheduler (reactor-side caller, including the watcher itself),
1204
+ # or no live watcher: when the reactor is gone its task fibers are
1205
+ # dead, so stopping them no longer touches Fiber.scheduler.
1206
+ close_now
1207
+ end
1208
+ end
1209
+
1210
+ private
1211
+
1212
+ def close_now
1213
+ # First caller wins: a reactor-side close racing a watcher-served
1214
+ # foreign close (or a repeated disconnect) must not re-run teardown
1215
+ # against half-torn-down state — transport.close can suspend mid-reap,
1216
+ # and a second pass would race it. Later callers return immediately;
1217
+ # the SDK's outer teardown ensures (query() / Client#disconnect) close
1218
+ # the transport independently, so nothing is left dangling even if the
1219
+ # first pass failed partway.
1220
+ first_caller = @close_mutex.synchronize do
1221
+ if @close_started
1222
+ false
1223
+ else
1224
+ @close_started = true
1225
+ end
1226
+ end
1227
+ return unless first_caller
1228
+
1153
1229
  @closed = true
1154
1230
  # Wake pending control-request waiters (same shape as the read-loop
1155
1231
  # rescue broadcast): close stops the read task with Async::Stop, which
@@ -1167,10 +1243,43 @@ module ClaudeAgentSDK
1167
1243
  # Stop tracked child tasks (e.g. stream_input) before the read task and
1168
1244
  # transport so a parked input stream can never keep the reactor alive
1169
1245
  # (mirrors Python close() cancelling _child_tasks).
1170
- @child_tasks.each(&:stop)
1171
- @child_tasks.clear
1172
- @task&.stop
1246
+ begin
1247
+ @child_tasks.each(&:stop)
1248
+ @child_tasks.clear
1249
+ @task&.stop
1250
+ rescue NoMethodError, FiberError => e
1251
+ # Schedulerless fallback close (the watcher died unserved) racing
1252
+ # reactor teardown: a task fiber can still be unwinding, and stopping
1253
+ # it needs the owning thread's Fiber.scheduler. The dying reactor
1254
+ # stops its own tasks; the transport close below (plain IO + kill)
1255
+ # still runs. On the reactor itself this is a real bug — re-raise.
1256
+ raise unless Fiber.scheduler.nil?
1257
+
1258
+ warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
1259
+ end
1173
1260
  @transport.close
1261
+ # Release a still-parked close watcher: pop returns nil and it exits
1262
+ # without serving. Any foreign-thread close arriving after this point
1263
+ # falls back to a direct close (safe — the fibers are now dead).
1264
+ @close_requests.close
1265
+ end
1266
+
1267
+ # Hand the close to the reactor and wait for completion. Polls watcher
1268
+ # liveness instead of waiting forever: if the reactor shuts down
1269
+ # concurrently (the transient watcher is stopped without serving the
1270
+ # request), no reply will ever arrive — fall back to a direct close,
1271
+ # which is safe once the reactor's fibers are dead.
1272
+ def marshal_close_to_reactor
1273
+ reply = ::Thread::Queue.new
1274
+ @close_requests << reply
1275
+ loop do
1276
+ return if reply.pop(timeout: 0.1)
1277
+ break unless @close_watcher&.alive?
1278
+ end
1279
+ close_now
1280
+ rescue ClosedQueueError
1281
+ # The push raced a reactor-side close_now that closed @close_requests.
1282
+ close_now
1174
1283
  end
1175
1284
  end
1176
1285
  end
@@ -28,6 +28,23 @@ module ClaudeAgentSDK
28
28
  def cleanup
29
29
  SessionResume.rmtree_with_retry(@config_dir)
30
30
  end
31
+
32
+ # Teardown when the transcript mirror dropped batches: the CLI's
33
+ # authoritative transcript lives in this temp dir, and the store copy is
34
+ # missing the dropped turns — deleting the dir would permanently lose
35
+ # them. Keep the transcripts (projects/), remove the redacted credential
36
+ # copies, and tell the user where the data is so they can import it into
37
+ # the store manually. Never raises.
38
+ def preserve_transcripts
39
+ ['.credentials.json', '.claude.json'].each do |name|
40
+ FileUtils.rm_f(File.join(@config_dir, name))
41
+ end
42
+ warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
43
+ "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
44
+ 'deleting it — import it into your session store, then remove the directory.'
45
+ rescue StandardError => e
46
+ warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}"
47
+ end
31
48
  end
32
49
 
33
50
  # Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
@@ -151,7 +168,7 @@ module ClaudeAgentSDK
151
168
  end
152
169
  return nil if sessions.nil? || sessions.empty?
153
170
 
154
- sessions.sort_by { |s| -(s['mtime'] || 0) }.each do |cand|
171
+ sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
155
172
  sid = cand['session_id']
156
173
  next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
157
174
 
@@ -166,6 +183,22 @@ module ClaudeAgentSDK
166
183
  nil
167
184
  end
168
185
 
186
+ # Adapters contractually report mtime as an epoch-ms Numeric (the
187
+ # conformance suite asserts it), but SQL timestamps naturally arrive as
188
+ # ISO-8601 Strings through JSON. Unary minus on a String is String#-@
189
+ # (frozen-string dedup), so String mtimes sorted lexicographically
190
+ # ASCENDING — --continue silently resumed the OLDEST session — and mixed
191
+ # Integer/String lists raised a bare ArgumentError. Coerce defensively:
192
+ # numeric strings and ISO-8601 both order correctly; anything else sorts
193
+ # last rather than crashing resume.
194
+ def sortable_mtime(value)
195
+ case value
196
+ when Numeric then value
197
+ when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
198
+ else 0
199
+ end
200
+ end
201
+
169
202
  # Run a store call (user code) on a plain thread bounded by timeout_s,
170
203
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
171
204
  # (FiberBoundary with a timeout always hops) both keeps the async scheduler
@@ -436,7 +469,7 @@ module ClaudeAgentSDK
436
469
  value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
437
470
  end
438
471
 
439
- private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
472
+ private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
440
473
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
441
474
  :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
442
475
  :resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
@@ -213,6 +213,65 @@ module ClaudeAgentSDK
213
213
  result
214
214
  end
215
215
 
216
+ # Byte-scan for `"key":"` like extract_json_string_field, but verify each
217
+ # match by JSON-parsing its containing line and reading the key at the TOP
218
+ # LEVEL of the entry. The raw scan also matches keys nested inside
219
+ # tool_use inputs (real transcripts carry unescaped `"summary":"..."` in
220
+ # subagent/teammate tool arguments), which made the disk path report
221
+ # tool-argument text as the session summary/title while the store fold —
222
+ # which reads only top-level keys — disagreed. A line that doesn't parse
223
+ # (truncated at the head/tail window edge) keeps the raw-scan value: its
224
+ # top-level shape can't be checked, and dropping it would regress the
225
+ # common case of a true entry cut by the 64KB window.
226
+ def extract_top_level_string_field(text, key, last: false)
227
+ positions = field_match_positions(text, key)
228
+ positions.reverse! if last
229
+ parsed_lines = {}
230
+ positions.each do |idx, value_start|
231
+ line_start = (text.rindex("\n", idx) || -1) + 1
232
+ entry = parsed_lines.fetch(line_start) do
233
+ parsed_lines[line_start] = parse_containing_line(text, line_start, idx)
234
+ end
235
+ if entry
236
+ value = entry[key]
237
+ return value if value.is_a?(String)
238
+
239
+ next # parseable line without a top-level string value: nested/false match
240
+ end
241
+ value = extract_json_string_value(text, value_start)
242
+ return unescape_json_string(value) if value
243
+ end
244
+ nil
245
+ end
246
+
247
+ # Match positions of both compact and spaced `"key":` patterns, sorted by
248
+ # position so first/last selection is by document order (the pattern-major
249
+ # order of extract_json_string_field is wrong when spacings mix).
250
+ def field_match_positions(text, key)
251
+ positions = []
252
+ ["\"#{key}\":\"", "\"#{key}\": \""].each do |pattern|
253
+ pos = 0
254
+ while (idx = text.index(pattern, pos))
255
+ value_start = idx + pattern.length
256
+ positions << [idx, value_start]
257
+ pos = value_start
258
+ end
259
+ end
260
+ positions.sort_by!(&:first)
261
+ positions
262
+ end
263
+
264
+ # Parse the JSONL line containing byte offset +idx+. Returns the entry
265
+ # Hash, {} for parseable non-Hash lines (a match inside one is nested by
266
+ # definition), or nil when the line doesn't parse (window truncation).
267
+ def parse_containing_line(text, line_start, idx)
268
+ line_end = text.index("\n", idx) || text.length
269
+ entry = JSON.parse(text[line_start...line_end])
270
+ entry.is_a?(Hash) ? entry : {}
271
+ rescue StandardError
272
+ nil
273
+ end
274
+
216
275
  # Extract string value starting at pos (handles escapes)
217
276
  def extract_json_string_value(text, start)
218
277
  pos = start
@@ -236,6 +295,19 @@ module ClaudeAgentSDK
236
295
  str
237
296
  end
238
297
 
298
+ # Python's `x or None` for the summary/title fallback chains: Ruby's ||
299
+ # treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
300
+ # would win over a real first prompt and then fail the summary presence
301
+ # check — silently dropping the whole session from disk listings (the
302
+ # store path, SessionSummary.presence, already falls through correctly).
303
+ # Whitespace-only counts as blank because the final gate strips.
304
+ def presence(val)
305
+ return nil if val.nil?
306
+ return nil if val.is_a?(String) && val.strip.empty?
307
+
308
+ val
309
+ end
310
+
239
311
  # Extract the first meaningful user prompt from the head of a JSONL file
240
312
  def extract_first_prompt_from_head(head)
241
313
  command_fallback = nil
@@ -247,16 +319,8 @@ module ClaudeAgentSDK
247
319
  next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
248
320
 
249
321
  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
322
+ texts = user_entry_texts(entry)
323
+ next unless texts
260
324
 
261
325
  texts.each do |text|
262
326
  text = text.gsub(/\n+/, ' ').strip
@@ -278,6 +342,29 @@ module ClaudeAgentSDK
278
342
  command_fallback || ''
279
343
  end
280
344
 
345
+ # Text blocks of a genuine user entry, or nil when the line should be
346
+ # skipped. Shape guards ported from Python: the byte pre-filter can match
347
+ # `"type":"user"` nested inside a tool_use input on an assistant line, so
348
+ # the parsed type is rechecked; and a malformed head line (non-Hash entry,
349
+ # string `message`, non-string `text`) must skip just that line rather
350
+ # than blow up into read_session_lite's blanket rescue and silently drop
351
+ # the whole session from disk listings.
352
+ def user_entry_texts(entry)
353
+ return nil unless entry.is_a?(Hash) && entry['type'] == 'user'
354
+
355
+ message = entry['message']
356
+ return nil unless message.is_a?(Hash)
357
+
358
+ content = message['content']
359
+ if content.is_a?(String)
360
+ [content]
361
+ elsif content.is_a?(Array)
362
+ content.filter_map do |block|
363
+ block['text'] if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String)
364
+ end
365
+ end
366
+ end
367
+
281
368
  # Read a single session file with lite (head/tail) strategy
282
369
  def read_session_lite(file_path, project_path)
283
370
  stat = File.stat(file_path)
@@ -311,15 +398,21 @@ module ClaudeAgentSDK
311
398
  def build_session_info(file_path, head, tail, stat, project_path)
312
399
  # User-set title (customTitle) wins over AI-generated title (aiTitle).
313
400
  # 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)
401
+ # Each candidate passes through presence so a blank value (e.g. a
402
+ # trailing title-clearing entry) falls through instead of short-circuiting.
403
+ # Summary-chain fields use the top-level-verified scan: a raw byte scan
404
+ # also matches these keys nested inside tool_use inputs, reporting tool
405
+ # arguments as the session title/summary (and diverging from the store
406
+ # fold, which reads top-level keys only).
407
+ custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
408
+ presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
409
+ presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
410
+ presence(extract_top_level_string_field(head, 'aiTitle', last: true))
318
411
  first_prompt = extract_first_prompt_from_head(head)
319
412
  # lastPrompt tail entry shows what the user was most recently doing.
320
413
  summary = custom_title ||
321
- extract_json_string_field(tail, 'lastPrompt', last: true) ||
322
- extract_json_string_field(tail, 'summary', last: true) ||
414
+ presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
415
+ presence(extract_top_level_string_field(tail, 'summary', last: true)) ||
323
416
  first_prompt
324
417
  return nil if summary.nil? || summary.strip.empty?
325
418
 
@@ -1235,7 +1328,7 @@ module ClaudeAgentSDK
1235
1328
  :find_session_file, :stat_candidate, :resolve_subagents_dir,
1236
1329
  :collect_agent_files, :parse_jsonl_entries,
1237
1330
  :build_conversation_chain, :walk_to_leaf, :walk_to_root,
1238
- :filter_visible_messages, :read_head_tail, :build_session_info,
1331
+ :filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
1239
1332
  :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
1240
1333
  :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
1241
1334
  :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: 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,
584
590
  stderr: stderr_text
585
591
  )
586
592
  raise @exit_error
@@ -55,12 +55,24 @@ module ClaudeAgentSDK
55
55
  @pending = []
56
56
  @pending_entries = 0
57
57
  @pending_bytes = 0
58
+ # Batches that exhausted retries (or could not be keyed) and never
59
+ # reached the store. Written only under @lock; read cross-thread by
60
+ # #batches_dropped? (a plain Integer read is safe under the GVL).
61
+ @dropped_batches = 0
58
62
  # Fiber-aware lock: the critical section blocks on SessionStore#append
59
63
  # (a thread hop), so a Thread::Mutex would deadlock the reactor. The
60
64
  # semaphore serializes drains so append ordering matches enqueue order.
61
65
  @lock = Async::Semaphore.new(1)
62
66
  end
63
67
 
68
+ # True when at least one batch of entries never reached the store — the
69
+ # mirror copy is incomplete. Consulted at teardown by the resume-from-store
70
+ # cleanup so the materialized temp dir (which then holds the only copy of
71
+ # the dropped turns) is preserved instead of deleted.
72
+ def batches_dropped?
73
+ @dropped_batches.positive?
74
+ end
75
+
64
76
  # Buffer a frame; schedule an eager background flush if thresholds are
65
77
  # exceeded. Synchronous and fire-and-forget.
66
78
  #
@@ -112,27 +124,47 @@ module ClaudeAgentSDK
112
124
  @pending_bytes = 0
113
125
 
114
126
  errors = []
115
- @lock.acquire do
116
- # Emptiness is checked INSIDE the lock (matching the Python batcher):
117
- # an empty #flush/#close still serializes behind any in-flight or
118
- # queued drain, so they are true barriers at result-yield and at
119
- # teardown the store really is up to date, and Query#close can't stop
120
- # the read task while a detached batch is still being appended.
121
- next if items.empty?
122
-
123
- do_flush(items, errors)
124
- rescue StandardError => e
125
- # do_flush already guards each append; this guards any remaining path
126
- # so the "never raises" contract holds against future regressions.
127
- warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
128
- end
129
-
130
- # Report errors after releasing the lock so a slow on_error callback can't
131
- # block subsequent drains (which only need the lock for append ordering).
132
- errors.each do |key, message|
133
- @on_error.call(key, message)
134
- rescue StandardError => e
135
- warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
127
+ # Cancellation (Async::Stop — not a StandardError) delivered while
128
+ # waiting on the lock or inside the append's thread join loses the
129
+ # detached items without either rescue firing. Teardown would then read
130
+ # batches_dropped? as false and delete a materialized resume dir holding
131
+ # the only copy of these entries count the batch as dropped unless the
132
+ # flush path ran to completion (do_flush counts its own failures).
133
+ accounted = items.empty?
134
+ begin
135
+ @lock.acquire do
136
+ # Emptiness is checked INSIDE the lock (matching the Python batcher):
137
+ # an empty #flush/#close still serializes behind any in-flight or
138
+ # queued drain, so they are true barriers at result-yield and at
139
+ # teardown the store really is up to date, and Query#close can't stop
140
+ # the read task while a detached batch is still being appended.
141
+ next if items.empty?
142
+
143
+ begin
144
+ do_flush(items, errors)
145
+ rescue StandardError => e
146
+ # do_flush already guards each append; this guards any remaining path
147
+ # so the "never raises" contract holds against future regressions.
148
+ @dropped_batches += 1
149
+ warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
150
+ end
151
+ accounted = true
152
+
153
+ # Report errors BEFORE releasing the lock: flush/close are barriers, so
154
+ # a caller observing flush completion must also observe the error
155
+ # report. Reporting after release let an in-flight eager drain enqueue
156
+ # its MirrorErrorMessage after the read loop's 'end' sentinel — past
157
+ # the point where consumers stop dequeuing, i.e. never delivered. The
158
+ # production on_error (Query#report_mirror_error) is a non-blocking
159
+ # queue push, so holding the lock across it costs nothing.
160
+ errors.each do |key, message|
161
+ @on_error.call(key, message)
162
+ rescue StandardError => e
163
+ warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
164
+ end
165
+ end
166
+ ensure
167
+ @dropped_batches += 1 unless accounted
136
168
  end
137
169
  end
138
170
 
@@ -149,6 +181,7 @@ module ClaudeAgentSDK
149
181
 
150
182
  key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
151
183
  if key.nil?
184
+ @dropped_batches += 1
152
185
  warn "Claude SDK: [SessionStore] dropping mirror frame: filePath #{file_path} is not under " \
153
186
  "#{@projects_dir} -- subprocess CLAUDE_CONFIG_DIR likely differs from parent (custom env / container?)"
154
187
  next
@@ -180,8 +213,11 @@ module ClaudeAgentSDK
180
213
  end
181
214
  end
182
215
 
183
- errors << [key, last_err.to_s] unless succeeded
184
- warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}" unless succeeded
216
+ return if succeeded
217
+
218
+ @dropped_batches += 1
219
+ errors << [key, last_err.to_s]
220
+ warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}"
185
221
  end
186
222
 
187
223
  # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
@@ -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.1'
4
+ VERSION = '0.21.0'
5
5
  end
@@ -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
 
@@ -530,8 +555,13 @@ module ClaudeAgentSDK
530
555
  ensure
531
556
  # Remove the materialized resume temp dir (which holds a redacted
532
557
  # .credentials.json copy) AFTER the subprocess has exited, even when
533
- # close itself raises.
534
- materialized&.cleanup
558
+ # close itself raises — unless the mirror dropped batches: the store
559
+ # copy is then incomplete and the temp dir holds the only copy of
560
+ # the dropped turns, so it is preserved (scrubbed of credentials)
561
+ # with a warning instead of deleted.
562
+ if materialized
563
+ query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
564
+ end
535
565
  end
536
566
  end
537
567
  end.wait
@@ -884,6 +914,10 @@ module ClaudeAgentSDK
884
914
  # state, and removes the materialized temp dir (which holds a redacted
885
915
  # .credentials.json copy) — so disconnect can never leave the client
886
916
  # half-open or leak the temp dir. The original error still propagates.
917
+ # Keep a handle on the query handler past the nil-out below: whether the
918
+ # mirror dropped batches is only final AFTER #close ran its last flush,
919
+ # and the materialized-dir decision at the bottom needs to ask it.
920
+ query_handler = @query_handler
887
921
  begin
888
922
  @query_handler&.close
889
923
  ensure
@@ -893,9 +927,17 @@ module ClaudeAgentSDK
893
927
  ensure
894
928
  @transport = nil
895
929
  @connected = false
896
- # Remove the materialized resume temp dir AFTER the subprocess exited.
930
+ # Remove the materialized resume temp dir AFTER the subprocess
931
+ # exited — unless the mirror dropped batches: the store copy is then
932
+ # incomplete and the temp dir holds the only copy of the dropped
933
+ # turns, so it is preserved (scrubbed of credentials) with a warning
934
+ # instead of deleted.
897
935
  if @materialized
898
- @materialized.cleanup
936
+ if query_handler&.mirror_batches_dropped?
937
+ @materialized.preserve_transcripts
938
+ else
939
+ @materialized.cleanup
940
+ end
899
941
  @materialized = nil
900
942
  end
901
943
  end
@@ -935,7 +977,7 @@ module ClaudeAgentSDK
935
977
 
936
978
  # Extract exclude_dynamic_sections from preset system prompt for the
937
979
  # initialize request (older CLIs ignore unknown initialize fields)
938
- exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
980
+ exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
939
981
 
940
982
  # Create Query handler
941
983
  @query_handler = Query.new(
@@ -972,22 +1014,13 @@ module ClaudeAgentSDK
972
1014
  # yielding deadlocked connect — and serialized Hash messages with
973
1015
  # to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
974
1016
  # and is tracked on the Query so close() stops it. Stream errors are
975
- # notified to observers once, then swallowed-with-warn by
976
- # stream_input (Python parity) they no longer abort connect.
1017
+ # swallowed-with-warn by stream_input (Python parity) — they don't
1018
+ # abort connect, and observers are NOT notified (the documented
1019
+ # Observer#on_error contract; notifying a swallowed error would mark
1020
+ # a still-live OTel trace as failed). Same behavior as query()'s
1021
+ # streaming path.
977
1022
  observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
978
- notifying = error_notifying_stream(observed)
979
- @query_handler.spawn_task { @query_handler.stream_input(notifying) }
980
- end
981
- end
982
-
983
- # Wrap a stream so a raising user enumerator fires on_error exactly once
984
- # before stream_input's swallow-with-warn handling takes over.
985
- def error_notifying_stream(stream)
986
- Enumerator.new do |yielder|
987
- stream.each { |message| yielder << message }
988
- rescue StandardError => e
989
- notify_error(e)
990
- raise
1023
+ @query_handler.spawn_task { @query_handler.stream_input(observed) }
991
1024
  end
992
1025
  end
993
1026
 
@@ -1058,20 +1091,6 @@ module ClaudeAgentSDK
1058
1091
  internal_hooks
1059
1092
  end
1060
1093
 
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
1094
  def writeln(string)
1076
1095
  write string.end_with?("\n") ? string : "#{string}\n"
1077
1096
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors