claude-agent-sdk 0.20.0 → 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: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
4
- data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
3
+ metadata.gz: 408fe2ed3f2f1f00866e3cef0b1795e67dc50dafd3292fc016985f62c10f1b6e
4
+ data.tar.gz: 045b04fee0681072fe7e99c81ab7a99d51d2eec362baf282a45c8d825cc38135
5
5
  SHA512:
6
- metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
7
- data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
6
+ metadata.gz: 91b04e46d137103527e3af91d089acc01be96b69a8b0e6902eecb603654567ee826c8284ac0f9b797f180af64220ecfe0d71c3ab6f5e755e03525d596fcff85e
7
+ data.tar.gz: fb7e3d0a535522e98ed3c7a0180f1ac35ca5057a29dfdb3429b07cd896375672c0c94e114d92809325680f599ca39d8dee2f7fc3a658a73fabd744cf4a987946
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ 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
+
10
23
  ## [0.20.0] - 2026-07-03
11
24
 
12
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`.
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.20.0'
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
 
@@ -376,7 +376,14 @@ module ClaudeAgentSDK
376
376
  end
377
377
 
378
378
  def load_settings_file(path)
379
- 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
380
387
 
381
388
  JSON.parse(File.read(path))
382
389
  end
@@ -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
@@ -1152,8 +1187,45 @@ module ClaudeAgentSDK
1152
1187
  end
1153
1188
  end
1154
1189
 
1155
- # 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.
1156
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
+
1157
1229
  @closed = true
1158
1230
  # Wake pending control-request waiters (same shape as the read-loop
1159
1231
  # rescue broadcast): close stops the read task with Async::Stop, which
@@ -1171,10 +1243,43 @@ module ClaudeAgentSDK
1171
1243
  # Stop tracked child tasks (e.g. stream_input) before the read task and
1172
1244
  # transport so a parked input stream can never keep the reactor alive
1173
1245
  # (mirrors Python close() cancelling _child_tasks).
1174
- @child_tasks.each(&:stop)
1175
- @child_tasks.clear
1176
- @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
1177
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
1178
1283
  end
1179
1284
  end
1180
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.
@@ -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
@@ -341,15 +400,19 @@ module ClaudeAgentSDK
341
400
  # Head fallback covers short sessions where the title entry may not be in tail.
342
401
  # Each candidate passes through presence so a blank value (e.g. a
343
402
  # 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))
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))
348
411
  first_prompt = extract_first_prompt_from_head(head)
349
412
  # lastPrompt tail entry shows what the user was most recently doing.
350
413
  summary = custom_title ||
351
- presence(extract_json_string_field(tail, 'lastPrompt', last: true)) ||
352
- presence(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)) ||
353
416
  first_prompt
354
417
  return nil if summary.nil? || summary.strip.empty?
355
418
 
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.20.0'
4
+ VERSION = '0.21.0'
5
5
  end
@@ -555,8 +555,13 @@ module ClaudeAgentSDK
555
555
  ensure
556
556
  # Remove the materialized resume temp dir (which holds a redacted
557
557
  # .credentials.json copy) AFTER the subprocess has exited, even when
558
- # close itself raises.
559
- 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
560
565
  end
561
566
  end
562
567
  end.wait
@@ -909,6 +914,10 @@ module ClaudeAgentSDK
909
914
  # state, and removes the materialized temp dir (which holds a redacted
910
915
  # .credentials.json copy) — so disconnect can never leave the client
911
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
912
921
  begin
913
922
  @query_handler&.close
914
923
  ensure
@@ -918,9 +927,17 @@ module ClaudeAgentSDK
918
927
  ensure
919
928
  @transport = nil
920
929
  @connected = false
921
- # 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.
922
935
  if @materialized
923
- @materialized.cleanup
936
+ if query_handler&.mirror_batches_dropped?
937
+ @materialized.preserve_transcripts
938
+ else
939
+ @materialized.cleanup
940
+ end
924
941
  @materialized = nil
925
942
  end
926
943
  end
@@ -997,22 +1014,13 @@ module ClaudeAgentSDK
997
1014
  # yielding deadlocked connect — and serialized Hash messages with
998
1015
  # to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
999
1016
  # and is tracked on the Query so close() stops it. Stream errors are
1000
- # notified to observers once, then swallowed-with-warn by
1001
- # 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.
1002
1022
  observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
1003
- notifying = error_notifying_stream(observed)
1004
- @query_handler.spawn_task { @query_handler.stream_input(notifying) }
1005
- end
1006
- end
1007
-
1008
- # Wrap a stream so a raising user enumerator fires on_error exactly once
1009
- # before stream_input's swallow-with-warn handling takes over.
1010
- def error_notifying_stream(stream)
1011
- Enumerator.new do |yielder|
1012
- stream.each { |message| yielder << message }
1013
- rescue StandardError => e
1014
- notify_error(e)
1015
- raise
1023
+ @query_handler.spawn_task { @query_handler.stream_input(observed) }
1016
1024
  end
1017
1025
  end
1018
1026
 
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.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors