claude-agent-sdk 0.33.1 → 0.35.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +54 -19
  4. data/docs/cli-installer.md +40 -9
  5. data/docs/client.md +27 -18
  6. data/docs/configuration.md +5 -5
  7. data/docs/errors.md +4 -3
  8. data/docs/mcp-servers.md +22 -0
  9. data/docs/observability.md +6 -0
  10. data/docs/rails.md +92 -51
  11. data/docs/sessions.md +66 -15
  12. data/lib/claude_agent_sdk/cli_installer.rb +34 -18
  13. data/lib/claude_agent_sdk/command_builder.rb +11 -3
  14. data/lib/claude_agent_sdk/configuration.rb +54 -2
  15. data/lib/claude_agent_sdk/errors.rb +11 -3
  16. data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
  17. data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
  18. data/lib/claude_agent_sdk/query.rb +140 -59
  19. data/lib/claude_agent_sdk/railtie.rb +94 -0
  20. data/lib/claude_agent_sdk/sdk_mcp_server.rb +46 -4
  21. data/lib/claude_agent_sdk/session_mutations.rb +39 -12
  22. data/lib/claude_agent_sdk/session_resume.rb +112 -39
  23. data/lib/claude_agent_sdk/session_store.rb +19 -3
  24. data/lib/claude_agent_sdk/session_summary.rb +5 -5
  25. data/lib/claude_agent_sdk/sessions.rb +123 -55
  26. data/lib/claude_agent_sdk/streaming.rb +0 -8
  27. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
  28. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +30 -0
  29. data/lib/claude_agent_sdk/tasks.rb +13 -0
  30. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
  31. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
  32. data/lib/claude_agent_sdk/types.rb +349 -39
  33. data/lib/claude_agent_sdk/version.rb +1 -1
  34. data/lib/claude_agent_sdk.rb +53 -44
  35. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  36. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  37. metadata +29 -13
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'securerandom'
5
5
  require 'fileutils'
6
+ require 'tempfile'
6
7
  require_relative 'sessions'
7
8
  require_relative 'session_store'
8
9
 
@@ -135,17 +136,14 @@ module ClaudeAgentSDK
135
136
  )
136
137
 
137
138
  fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
138
- io = nil
139
- fd = IO.sysopen(fork_path, File::WRONLY | File::CREAT | File::EXCL, 0o600)
140
- begin
141
- io = IO.new(fd)
139
+ # Stage beside the destination, outside the *.jsonl browsing glob. A hard
140
+ # link publishes the closed, complete file atomically without overwriting
141
+ # an existing UUID (rename would replace it). Tempfile owns only the
142
+ # staging name, so failure cleanup never removes somebody else's session.
143
+ Tempfile.create(['.claude-fork-', '.tmp'], project_dir) do |io|
142
144
  io.write("#{lines.join("\n")}\n")
143
- ensure
144
- if io
145
- io.close
146
- else
147
- IO.for_fd(fd).close rescue nil # rubocop:disable Style/RescueModifier
148
- end
145
+ io.close
146
+ File.link(io.path, fork_path)
149
147
  end
150
148
 
151
149
  ForkSessionResult.new(session_id: forked_session_id)
@@ -159,6 +157,7 @@ module ClaudeAgentSDK
159
157
  # by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
160
158
  #
161
159
  # @raise [ArgumentError] if session_id is invalid or title is empty
160
+ # @raise [Errno::ENOENT] if the session is not found in the store
162
161
  def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
163
162
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
164
163
 
@@ -166,6 +165,7 @@ module ClaudeAgentSDK
166
165
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
167
166
 
168
167
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
168
+ ensure_store_session_exists(session_store, key)
169
169
  session_store.append(key, [{
170
170
  'type' => 'custom-title',
171
171
  'customTitle' => stripped,
@@ -181,6 +181,7 @@ module ClaudeAgentSDK
181
181
  # Unicode-sanitized before storing.
182
182
  #
183
183
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
184
+ # @raise [Errno::ENOENT] if the session is not found in the store
184
185
  def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
185
186
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
186
187
 
@@ -192,6 +193,7 @@ module ClaudeAgentSDK
192
193
  end
193
194
 
194
195
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
196
+ ensure_store_session_exists(session_store, key)
195
197
  session_store.append(key, [{
196
198
  'type' => 'tag',
197
199
  'tag' => tag || '',
@@ -251,6 +253,27 @@ module ClaudeAgentSDK
251
253
 
252
254
  # -- Private helpers --
253
255
 
256
+ # Raise Errno::ENOENT (as the disk counterparts and fork_session_via_store
257
+ # do) unless the store holds entries for +key+. Without this probe, a
258
+ # rename/tag of a typo'd or stale id APPENDED metadata to a never-written
259
+ # key, creating a phantom session — permanent on WORM/append-only stores.
260
+ #
261
+ # #load is the probe because it is the only exact per-session existence
262
+ # check the contract offers: it is required, and returns nil for a key
263
+ # that was never written. The optional methods don't fit: list_subkeys
264
+ # returns [] for "no subagents" and "no session" alike,
265
+ # list_session_summaries is an advisory sidecar that may be stale, and
266
+ # list_sessions scans the whole project (no cheaper than one load in the
267
+ # reference adapters).
268
+ #
269
+ # Check-then-act: a concurrent delete between this probe and the append
270
+ # can still recreate the key. That window is inherent to the store API
271
+ # (there is no conditional append), so no locking is attempted.
272
+ def ensure_store_session_exists(session_store, key)
273
+ entries = session_store.load(key)
274
+ raise Errno::ENOENT, "Session #{key['session_id']} not found" if entries.nil? || entries.empty?
275
+ end
276
+
254
277
  # Locate the JSONL file for a session and return [file_path, project_dir].
255
278
  def find_session_file_with_dir(session_id, directory)
256
279
  file_name = "#{session_id}.jsonl"
@@ -600,7 +623,10 @@ module ClaudeAgentSDK
600
623
  File.open(path, File::WRONLY | File::APPEND) do |file|
601
624
  return false if file.stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate
602
625
 
603
- file.write(data)
626
+ # The final JSONL record need not have a newline (or may be truncated).
627
+ # Append the boundary and metadata together, without a racy read/check
628
+ # or requiring read access. Readers already ignore empty lines.
629
+ file.write("\n#{data}")
604
630
  true
605
631
  end
606
632
  rescue Errno::ENOENT, Errno::ENOTDIR
@@ -642,6 +668,7 @@ module ClaudeAgentSDK
642
668
  :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
643
669
  :append_to_session, :append_to_session_in_directory,
644
670
  :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
645
- :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries
671
+ :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries,
672
+ :ensure_store_session_exists
646
673
  end
647
674
  end
@@ -138,18 +138,18 @@ module ClaudeAgentSDK
138
138
  # prevent traversal and match every other resume path.
139
139
  return nil unless options.resume.match?(Sessions::UUID_RE)
140
140
 
141
- load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper)
141
+ encode_candidate(load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper))
142
142
  else
143
143
  resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
144
144
  end
145
145
  return nil if resolved.nil?
146
146
 
147
- session_id, entries = resolved
147
+ session_id, lines = resolved
148
148
  tmp_base = Dir.mktmpdir('claude-resume-')
149
149
  begin
150
150
  project_dir = File.join(tmp_base, 'projects', project_key)
151
151
  FileUtils.mkdir_p(project_dir)
152
- write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), entries)
152
+ write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), lines)
153
153
 
154
154
  # The subprocess runs with CLAUDE_CONFIG_DIR=tmp_base; copy auth config
155
155
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
@@ -171,6 +171,7 @@ module ClaudeAgentSDK
171
171
  # -- Helpers --
172
172
 
173
173
  # Load entries for session_id; return [session_id, entries] or nil if empty.
174
+ # Callers pass the result through encode_candidate before writing.
174
175
  def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
175
176
  entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
176
177
  store.load('project_key' => project_key, 'session_id' => session_id)
@@ -192,7 +193,7 @@ module ClaudeAgentSDK
192
193
 
193
194
  sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
194
195
 
195
- sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
196
+ sessions.sort_by { |s| -Sessions.sortable_mtime(s['mtime']) }.each do |cand|
196
197
  sid = cand['session_id']
197
198
  next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
198
199
  # Skip known sidechains without downloading their transcript: the
@@ -203,14 +204,62 @@ module ClaudeAgentSDK
203
204
  loaded = load_candidate(store, project_key, sid, timeout_s, scheduling, wrapper)
204
205
  next if loaded.nil?
205
206
 
206
- first = loaded[1][0]
207
- next if first.is_a?(Hash) && first['isSidechain'] == true
207
+ encoded = encode_candidate(loaded)
208
+ next if encoded.nil?
208
209
 
209
- return loaded
210
+ # Classify from the first entry that is actually written (the first
211
+ # surviving object), not the raw head: a poisoned or non-Hash first
212
+ # entry would otherwise hide the isSidechain flag the rest carry and
213
+ # --continue would resume a subagent. Same rule as the disk reader.
214
+ head = encoded[2]
215
+ next if head && head['isSidechain'] == true
216
+
217
+ return encoded
210
218
  end
211
219
  nil
212
220
  end
213
221
 
222
+ # [session_id, entries] -> [session_id, jsonl_lines, head], or nil when no
223
+ # entry survives encoding; head is the first surviving Hash entry (nil if
224
+ # none). Encoding happens BEFORE the temp dir exists so a session whose
225
+ # entries are all unusable behaves exactly like an empty one: --resume
226
+ # falls through to the normal spawn path and --continue moves on to the
227
+ # next candidate.
228
+ def encode_candidate(loaded)
229
+ return nil if loaded.nil?
230
+
231
+ session_id, entries = loaded
232
+ what = "session #{session_id}"
233
+ head = nil
234
+ lines = entries.filter_map do |entry|
235
+ line = encode_entry(entry, what)
236
+ head ||= entry if line && entry.is_a?(Hash)
237
+ line
238
+ end
239
+ lines.empty? ? nil : [session_id, lines, head]
240
+ end
241
+
242
+ # Encode entries as JSON lines, dropping unserializable ones (see encode_entry).
243
+ def encode_jsonl_lines(entries, what)
244
+ entries.filter_map { |entry| encode_entry(entry, what) }
245
+ end
246
+
247
+ # Encode one store entry as a compact JSON line, or nil (with a warning
248
+ # naming its uuid when it has one) if it cannot be serialized:
249
+ # NaN/Infinity, invalid UTF-8, circular or over-deep nesting —
250
+ # JSON::NestingError is a ParserError, hence the JSONError rescue. Entries
251
+ # are opaque adapter pass-through, so one poisoned entry must not abort
252
+ # the whole resume: like an unusable sidecar on the disk side, an unusable
253
+ # entry is treated as absent.
254
+ def encode_entry(entry, what)
255
+ JSON.generate(entry)
256
+ rescue JSON::JSONError => e
257
+ uuid = entry.is_a?(Hash) ? entry['uuid'] : nil
258
+ warn "Claude SDK: [SessionStore] resume: skipping unserializable entry#{" uuid=#{uuid.inspect}" if uuid} " \
259
+ "in #{what} (#{e.class}: #{e.message})"
260
+ nil
261
+ end
262
+
214
263
  # session_id => true for sessions the summary sidecar marks as sidechains;
215
264
  # nil when the store doesn't implement list_session_summaries or the call
216
265
  # fails (callers then fall back to checking each full load). The per-load
@@ -231,22 +280,6 @@ module ClaudeAgentSDK
231
280
  nil
232
281
  end
233
282
 
234
- # Adapters contractually report mtime as an epoch-ms Numeric (the
235
- # conformance suite asserts it), but SQL timestamps naturally arrive as
236
- # ISO-8601 Strings through JSON. Unary minus on a String is String#-@
237
- # (frozen-string dedup), so String mtimes sorted lexicographically
238
- # ASCENDING — --continue silently resumed the OLDEST session — and mixed
239
- # Integer/String lists raised a bare ArgumentError. Coerce defensively:
240
- # numeric strings and ISO-8601 both order correctly; anything else sorts
241
- # last rather than crashing resume.
242
- def sortable_mtime(value)
243
- case value
244
- when Numeric then value
245
- when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
246
- else 0
247
- end
248
- end
249
-
250
283
  # Run a store call (user code) on a plain thread bounded by timeout_s,
251
284
  # re-raising failures/timeouts as RuntimeError with context. The thread hop
252
285
  # (the default for FiberBoundary with a timeout) both keeps the async
@@ -270,12 +303,13 @@ module ClaudeAgentSDK
270
303
  raise "#{what} failed during resume materialization: #{e}"
271
304
  end
272
305
 
273
- # Stream-write entries as one compact JSON line each (mode 0600).
274
- def write_jsonl(path, entries)
306
+ # Write pre-encoded JSON lines (see encode_jsonl_lines), one per line,
307
+ # mode 0600.
308
+ def write_jsonl(path, lines)
275
309
  FileUtils.mkdir_p(File.dirname(path))
276
310
  File.open(path, 'w') do |f|
277
- entries.each do |entry|
278
- f.write(JSON.generate(entry))
311
+ lines.each do |line|
312
+ f.write(line)
279
313
  f.write("\n")
280
314
  end
281
315
  end
@@ -291,14 +325,20 @@ module ClaudeAgentSDK
291
325
  # cowork_settings.json live under the config dir (default ~/.claude/), while
292
326
  # .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
293
327
  # ~/.claude.json (NOT ~/.claude/.claude.json).
328
+ #
329
+ # Without a usable home (see .home_dir) the home-relative sources are
330
+ # skipped like missing files: they cannot exist, and raising here aborted
331
+ # every store-backed resume on a HOME-less host — even API-key auth,
332
+ # which needs none of them.
294
333
  def copy_auth_files(tmp_base, opt_env)
295
334
  caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
296
- source_config_dir = caller_config_dir || File.join(Dir.home, '.claude')
335
+ home = caller_config_dir ? nil : home_dir
336
+ source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
297
337
 
298
338
  # read_if_present returns raw bytes; the credentials path parses and
299
339
  # re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes
300
340
  # simply fail to parse and get written through, as before).
301
- creds_bytes = read_if_present(File.join(source_config_dir, '.credentials.json'))
341
+ creds_bytes = source_config_dir && read_if_present(File.join(source_config_dir, '.credentials.json'))
302
342
  creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8)
303
343
 
304
344
  # macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting
@@ -313,8 +353,8 @@ module ClaudeAgentSDK
313
353
 
314
354
  write_redacted_credentials(creds_json, File.join(tmp_base, '.credentials.json'))
315
355
 
316
- claude_json_src = caller_config_dir ? File.join(caller_config_dir, '.claude.json') : File.join(Dir.home, '.claude.json')
317
- copy_if_present(claude_json_src, File.join(tmp_base, '.claude.json'))
356
+ claude_json_dir = caller_config_dir || home
357
+ copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json')) if claude_json_dir
318
358
 
319
359
  # User settings carry apiKeyHelper (a fourth auth mechanism alongside
320
360
  # .credentials.json / Keychain / env vars) plus the user's env, hooks and
@@ -323,6 +363,8 @@ module ClaudeAgentSDK
323
363
  # cowork_settings.json is the alternate filename the CLI reads in
324
364
  # cowork-plugins mode. Both pass through strip_settings_for_resume so
325
365
  # plugin declarations don't reconcile against the empty tmp_base cache.
366
+ return unless source_config_dir
367
+
326
368
  transform = ->(content) { strip_settings_for_resume(content) }
327
369
  SEEDED_SETTINGS_FILES.each do |name|
328
370
  copy_if_present(File.join(source_config_dir, name), File.join(tmp_base, name), transform)
@@ -565,22 +607,40 @@ module ClaudeAgentSDK
565
607
  metadata, transcript = Sessions.split_agent_metadata(entries)
566
608
  sub_file = File.join(session_dir, "#{subpath}.jsonl")
567
609
 
568
- write_jsonl(sub_file, transcript) unless transcript.empty?
610
+ lines = encode_jsonl_lines(transcript, "subpath #{subpath}")
611
+ write_jsonl(sub_file, lines) unless lines.empty?
569
612
 
570
613
  return if metadata.nil?
571
614
 
572
615
  # Strip the synthetic type field.
573
- meta_content = metadata.except('type')
616
+ meta_json = encode_agent_metadata(metadata.except('type'), subpath)
617
+ return if meta_json.nil?
618
+
574
619
  meta_file = Sessions.agent_metadata_sidecar_path(sub_file)
575
620
  FileUtils.mkdir_p(File.dirname(meta_file))
576
- File.write(meta_file, JSON.generate(meta_content))
621
+ File.write(meta_file, meta_json)
577
622
  chmod_owner_only(meta_file)
578
623
  end
579
624
 
580
- # Reject subpaths that are empty, absolute, drive/UNC-prefixed, contain "."
581
- # or ".." components or a NUL byte, or escape session_dir after resolution.
625
+ # An unserializable metadata sidecar is skipped (unusable = absent, as on
626
+ # the disk side) rather than aborting the resume.
627
+ def encode_agent_metadata(meta_content, subpath)
628
+ JSON.generate(meta_content)
629
+ rescue JSON::JSONError => e
630
+ warn "Claude SDK: [SessionStore] resume: skipping unserializable agent metadata " \
631
+ "for subpath #{subpath} (#{e.class}: #{e.message})"
632
+ nil
633
+ end
634
+
635
+ # Reject subpaths that are not Strings, empty, absolute, drive/UNC-prefixed,
636
+ # contain "." or ".." components or a NUL byte, or escape session_dir after
637
+ # resolution. A non-String subkey (Symbol, Integer) is an adapter contract
638
+ # violation: reject it like any other unsafe subkey (skip that entry)
639
+ # rather than guess at a coercion — calling String methods on it used to
640
+ # raise NoMethodError and abort the whole resume.
582
641
  def safe_subpath?(subpath, session_dir)
583
- return false if subpath.nil? || subpath.empty?
642
+ return false unless subpath.is_a?(String)
643
+ return false if subpath.empty?
584
644
  return false if subpath.start_with?('/', '\\')
585
645
  return false if subpath.match?(/\A[a-zA-Z]:/) # drive-prefixed (C:foo) / UNC
586
646
  return false if subpath.split(%r{[\\/]}).any? { |part| ['.', '..'].include?(part) }
@@ -698,11 +758,24 @@ module ClaudeAgentSDK
698
758
  value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
699
759
  end
700
760
 
701
- private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
761
+ # The parent's home directory, or nil when none is usable. Dir.home raises
762
+ # ArgumentError when HOME is unset and the uid has no passwd entry (docker
763
+ # --user in a minimal image), and returns an empty or relative HOME
764
+ # verbatim — reading under "" or a cwd-relative path would seed files the
765
+ # CLI never looks at. SubprocessCLITransport#home_dir applies the same rule.
766
+ def home_dir
767
+ home = Dir.home
768
+ home if File.absolute_path?(home)
769
+ rescue ArgumentError
770
+ nil
771
+ end
772
+
773
+ private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
702
774
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
703
775
  :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
704
776
  :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
705
777
  :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
706
- :redacted_credentials
778
+ :redacted_credentials, :home_dir, :encode_candidate, :encode_jsonl_lines, :encode_entry,
779
+ :encode_agent_metadata
707
780
  end
708
781
  end
@@ -29,6 +29,9 @@ module ClaudeAgentSDK
29
29
  # All keys/entries cross the adapter boundary as Hashes with STRING keys:
30
30
  # - SessionKey: { 'project_key' => String, 'session_id' => String,
31
31
  # 'subpath' => String (optional; omit for the main transcript) }
32
+ # Subagent reads on a store without #list_subkeys synthesize the subpath
33
+ # `subagents/agent-<agent_id>` from the caller's agent_id, which the SDK
34
+ # first restricts to [A-Za-z0-9._-]+ (never '.' or '..').
32
35
  # - entries: raw JSONL transcript objects (opaque pass-through blobs)
33
36
  # - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
34
37
  # - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
@@ -138,6 +141,8 @@ module ClaudeAgentSDK
138
141
  return if entries.nil? || entries.empty?
139
142
 
140
143
  @mutex.synchronize do
144
+ key = copy_json(key)
145
+ entries = copy_json(entries)
141
146
  k = key_to_string(key)
142
147
  (@store[k] ||= []).concat(entries)
143
148
  now_ms = next_mtime
@@ -160,7 +165,7 @@ module ClaudeAgentSDK
160
165
  def load(key)
161
166
  @mutex.synchronize do
162
167
  entries = @store[key_to_string(key)]
163
- entries&.dup
168
+ copy_json(entries)
164
169
  end
165
170
  end
166
171
 
@@ -187,7 +192,7 @@ module ClaudeAgentSDK
187
192
  @summaries.filter_map do |(pk, _sid), summary|
188
193
  next unless pk == project_key
189
194
 
190
- { 'session_id' => summary['session_id'], 'mtime' => summary['mtime'], 'data' => summary['data'].dup }
195
+ copy_json(summary)
191
196
  end
192
197
  end
193
198
  end
@@ -224,7 +229,7 @@ module ClaudeAgentSDK
224
229
 
225
230
  # All entries for a key (empty array if absent).
226
231
  def get_entries(key)
227
- @mutex.synchronize { (@store[key_to_string(key)] || []).dup }
232
+ load(key) || []
228
233
  end
229
234
 
230
235
  # Number of stored sessions (main transcripts only).
@@ -248,6 +253,17 @@ module ClaudeAgentSDK
248
253
 
249
254
  private
250
255
 
256
+ # JSON values are mutable down to their strings. Keep stored snapshots and
257
+ # returned values detached, just like a serialization-backed adapter.
258
+ def copy_json(value)
259
+ case value
260
+ when Hash then value.to_h { |k, v| [k, copy_json(v)] }
261
+ when Array then value.map { |v| copy_json(v) }
262
+ when String then value.dup
263
+ else value
264
+ end
265
+ end
266
+
251
267
  # True for a main-transcript key: no subpath, or an empty-string subpath
252
268
  # (which key_to_string already folds into the main key).
253
269
  def main_transcript_key?(key)
@@ -126,12 +126,12 @@ module ClaudeAgentSDK
126
126
  )
127
127
  end
128
128
 
129
- # Python's `x or None`: nil for nil/empty-string, else the value.
129
+ # Python's `x or None`, delegating to the disk path's Sessions.presence
130
+ # so both summary paths share ONE definition of blank (whitespace-only
131
+ # included). A local copy that only rejected "" let the store path list
132
+ # a session under an invisible whitespace summary the disk path hid.
130
133
  def presence(val)
131
- return nil if val.nil?
132
- return nil if val.is_a?(String) && val.empty?
133
-
134
- val
134
+ Sessions.presence(val)
135
135
  end
136
136
 
137
137
  # Replicate Sessions#extract_first_prompt_from_head for a single parsed