claude-agent-sdk 0.33.0 → 0.34.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.
@@ -61,6 +61,8 @@ module ClaudeAgentSDK
61
61
  end
62
62
 
63
63
  def self.ruby_type_to_json_schema(type)
64
+ # Class#=== matches instances, not the class object used in { id: Integer }.
65
+ type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
64
66
  case type
65
67
  when :string, String then { type: 'string' }
66
68
  when :integer, Integer then { type: 'integer' }
@@ -70,6 +72,24 @@ module ClaudeAgentSDK
70
72
  end
71
73
  end
72
74
 
75
+ # Internal: call a tool handler, reporting SystemExit / SignalException
76
+ # (Interrupt included) as an ordinary handler failure — re-raised as a
77
+ # RuntimeError (#cause holds the original) that both tools/call dispatch
78
+ # boundaries turn into an in-band isError result, so the pending control
79
+ # response is always written. Must run INSIDE the FiberBoundary.invoke
80
+ # block: a worker thread that dies with SystemExit has it re-raised by
81
+ # Ruby on the MAIN thread, tearing down the reactor, while the dispatcher
82
+ # only sees Async::Stop — a rescue after the hop cannot catch it in
83
+ # :thread mode. A callback_wrapper therefore observes the RuntimeError.
84
+ # Deliberately not `rescue Exception`: cancellation (Async::Stop, and
85
+ # InlineCancellation at an :inline suspension point) must propagate.
86
+ # @api private
87
+ def self.call_tool_handler(handler, arguments)
88
+ handler.call(arguments)
89
+ rescue SystemExit, SignalException => e
90
+ raise e.message
91
+ end
92
+
73
93
  # SDK MCP Server - wraps official MCP::Server with block-based API
74
94
  #
75
95
  # Unlike external MCP servers that run as separate processes, SDK MCP servers
@@ -79,6 +99,19 @@ module ClaudeAgentSDK
79
99
  # This class wraps the official MCP Ruby SDK and provides a simpler block-based
80
100
  # API for defining tools, resources, and prompts.
81
101
  class SdkMcpServer
102
+ # The gem validates arguments before injecting its server_context keyword.
103
+ # Guard actual keys here, independent of schema composition/$ref support,
104
+ # and retain this guard even when schema validation falls back to permissive.
105
+ class ToolInputSchema < MCP::Tool::InputSchema
106
+ def validate_arguments(arguments)
107
+ if arguments.is_a?(Hash) && (arguments.key?(:server_context) || arguments.key?('server_context'))
108
+ raise ValidationError, "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
109
+ end
110
+
111
+ super
112
+ end
113
+ end
114
+
82
115
  attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
83
116
 
84
117
  # Default for where user handlers run when this server is invoked
@@ -257,7 +290,7 @@ module ClaudeAgentSDK
257
290
  # AR/PG); in :inline mode it runs in place on the reactor fiber.
258
291
  scheduling, wrapper = effective_callback_dispatch
259
292
  result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
260
- tool.handler.call(arguments)
293
+ ClaudeAgentSDK.call_tool_handler(tool.handler, arguments)
261
294
  end
262
295
 
263
296
  # Guard before flexible_fetch: it raises on non-Hash inputs.
@@ -376,6 +409,15 @@ module ClaudeAgentSDK
376
409
  # mode at call time — same pattern as prompt classes.
377
410
  sdk_server = self
378
411
  tools.map do |tool_def|
412
+ # The gem injects server_context AFTER expanding the tool arguments,
413
+ # overwriting a user value before our call method can recover it.
414
+ # Check at registration (including raw SdkMcpTool definitions), not in
415
+ # input_schema_value's permissive schema-error fallback.
416
+ schema = ClaudeAgentSDK.normalize_tool_schema(tool_def.input_schema)
417
+ if schema[:properties]&.key?(:server_context)
418
+ raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
419
+ end
420
+
379
421
  # Create a new class that extends MCP::Tool
380
422
  Class.new(MCP::Tool) do
381
423
  @tool_def = tool_def
@@ -407,11 +449,11 @@ module ClaudeAgentSDK
407
449
  schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
408
450
  schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
409
451
  begin
410
- MCP::Tool::InputSchema.new(schema)
452
+ ToolInputSchema.new(schema)
411
453
  rescue ArgumentError => e
412
454
  warn "Claude SDK: tool '#{@tool_def.name}' schema not draft4-compatible " \
413
455
  "(#{e.message.lines.first&.strip}); argument validation disabled for this tool"
414
- MCP::Tool::InputSchema.new({ type: 'object', properties: {} })
456
+ ToolInputSchema.new({ type: 'object', properties: {} })
415
457
  end
416
458
  end
417
459
  end
@@ -434,7 +476,7 @@ module ClaudeAgentSDK
434
476
  # the Fiber scheduler; :inline runs in place on the reactor.
435
477
  scheduling, wrapper = @sdk_server.effective_callback_dispatch
436
478
  result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
437
- @tool_def.handler.call(args)
479
+ ClaudeAgentSDK.call_tool_handler(@tool_def.handler, args)
438
480
  end
439
481
 
440
482
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
@@ -453,6 +495,16 @@ module ClaudeAgentSDK
453
495
  error: !!is_error,
454
496
  structured_content: structured_content
455
497
  )
498
+ rescue StandardError => e
499
+ # Report handler failures in-band HERE rather than letting them
500
+ # reach the gem: mcp >= 1.2 deliberately drops e.message from
501
+ # its "Internal error calling tool X" wrapper (CWE-209), which
502
+ # would hide the text the model needs to self-correct. Bare
503
+ # e.message like Python's str(e) and #call_tool — no prefix.
504
+ # Nothing gem-internal can be swallowed here today: handlers get
505
+ # no server_context, so MCP::CancelledError never originates
506
+ # inside this method. Revisit if cancellation is ever plumbed in.
507
+ MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
456
508
  end
457
509
  end
458
510
  end
@@ -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
 
@@ -38,8 +39,7 @@ module ClaudeAgentSDK
38
39
  stripped = title.strip
39
40
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
40
41
 
41
- data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id },
42
- space_size: 0)}\n"
42
+ data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n"
43
43
 
44
44
  append_to_session(session_id, data, directory)
45
45
  end
@@ -64,8 +64,7 @@ module ClaudeAgentSDK
64
64
  tag = sanitized
65
65
  end
66
66
 
67
- data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id },
68
- space_size: 0)}\n"
67
+ data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n"
69
68
 
70
69
  append_to_session(session_id, data, directory)
71
70
  end
@@ -137,17 +136,14 @@ module ClaudeAgentSDK
137
136
  )
138
137
 
139
138
  fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
140
- io = nil
141
- fd = IO.sysopen(fork_path, File::WRONLY | File::CREAT | File::EXCL, 0o600)
142
- begin
143
- 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|
144
144
  io.write("#{lines.join("\n")}\n")
145
- ensure
146
- if io
147
- io.close
148
- else
149
- IO.for_fd(fd).close rescue nil # rubocop:disable Style/RescueModifier
150
- end
145
+ io.close
146
+ File.link(io.path, fork_path)
151
147
  end
152
148
 
153
149
  ForkSessionResult.new(session_id: forked_session_id)
@@ -161,6 +157,7 @@ module ClaudeAgentSDK
161
157
  # by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
162
158
  #
163
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
164
161
  def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
165
162
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
166
163
 
@@ -168,6 +165,7 @@ module ClaudeAgentSDK
168
165
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
169
166
 
170
167
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
168
+ ensure_store_session_exists(session_store, key)
171
169
  session_store.append(key, [{
172
170
  'type' => 'custom-title',
173
171
  'customTitle' => stripped,
@@ -183,6 +181,7 @@ module ClaudeAgentSDK
183
181
  # Unicode-sanitized before storing.
184
182
  #
185
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
186
185
  def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
187
186
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
188
187
 
@@ -194,6 +193,7 @@ module ClaudeAgentSDK
194
193
  end
195
194
 
196
195
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
196
+ ensure_store_session_exists(session_store, key)
197
197
  session_store.append(key, [{
198
198
  'type' => 'tag',
199
199
  'tag' => tag || '',
@@ -253,6 +253,27 @@ module ClaudeAgentSDK
253
253
 
254
254
  # -- Private helpers --
255
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
+
256
277
  # Locate the JSONL file for a session and return [file_path, project_dir].
257
278
  def find_session_file_with_dir(session_id, directory)
258
279
  file_name = "#{session_id}.jsonl"
@@ -602,7 +623,10 @@ module ClaudeAgentSDK
602
623
  File.open(path, File::WRONLY | File::APPEND) do |file|
603
624
  return false if file.stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate
604
625
 
605
- 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}")
606
630
  true
607
631
  end
608
632
  rescue Errno::ENOENT, Errno::ENOTDIR
@@ -644,6 +668,7 @@ module ClaudeAgentSDK
644
668
  :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
645
669
  :append_to_session, :append_to_session_in_directory,
646
670
  :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
647
- :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
648
673
  end
649
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