claude-agent-sdk 0.30.0 → 0.32.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.
@@ -63,7 +63,8 @@ module ClaudeAgentSDK
63
63
  end
64
64
 
65
65
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
66
- exclude_dynamic_sections: nil, skills: nil, callback_scheduling: :thread, callback_wrapper: nil)
66
+ exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
67
+ forward_subagent_text: false, callback_scheduling: :thread, callback_wrapper: nil)
67
68
  @transport = transport
68
69
  @is_streaming_mode = is_streaming_mode
69
70
  @can_use_tool = can_use_tool
@@ -73,7 +74,9 @@ module ClaudeAgentSDK
73
74
  @callback_wrapper = callback_wrapper
74
75
  @agents = agents
75
76
  @exclude_dynamic_sections = exclude_dynamic_sections
77
+ @system_prompt_snapshot = system_prompt_snapshot
76
78
  @skills = skills
79
+ @forward_subagent_text = forward_subagent_text
77
80
 
78
81
  # Control protocol state
79
82
  @pending_control_responses = {}
@@ -97,7 +100,12 @@ module ClaudeAgentSDK
97
100
  # #1088/#1103), so a result that arrives while this set is non-empty
98
101
  # must not close stdin.
99
102
  @inflight_tasks = Set.new
100
- @last_error_result_text = nil
103
+ # Set to the result payload when the most recent message is a result
104
+ # with is_error=true. Used to replace the generic "exit code 1"
105
+ # ProcessError with a ResultError carrying what the CLI already
106
+ # reported. Mirrors the TypeScript SDK's `lastErrorResultText`
107
+ # (Query.ts), but keeps the whole payload rather than just the text.
108
+ @last_error_result = nil
101
109
  @first_result_condition = Async::Condition.new
102
110
  @task = nil
103
111
  @child_tasks = []
@@ -182,9 +190,15 @@ module ClaudeAgentSDK
182
190
  agents: agents_dict
183
191
  }
184
192
  request[:excludeDynamicSections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
193
+ # false is meaningful (rebuild the prompt every request), so send it
194
+ # explicitly; only nil (unset) is omitted.
195
+ request[:systemPromptSnapshot] = @system_prompt_snapshot unless @system_prompt_snapshot.nil?
185
196
  # 'all' and omitted are equivalent at the wire level (no filter), so
186
197
  # only send the field when it's an explicit list (mirrors Python).
187
198
  request[:skills] = @skills if @skills.is_a?(Array)
199
+ # Off is the CLI default, so only send the field when enabled — an
200
+ # older CLI then never sees an unknown key on the common path.
201
+ request[:forwardSubagentText] = true if @forward_subagent_text
188
202
 
189
203
  response = send_control_request(request)
190
204
  @initialized = true
@@ -355,45 +369,52 @@ module ClaudeAgentSDK
355
369
  @first_result_condition.signal
356
370
  end
357
371
  if message[:is_error]
358
- errors = (message[:errors] || []).join('; ')
359
- @last_error_result_text = errors.empty? ? (message[:subtype] || 'unknown error').to_s : errors
372
+ @last_error_result = message
360
373
  else
361
- @last_error_result_text = nil
374
+ @last_error_result = nil
362
375
  end
363
376
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
364
377
  # Anything other than the post-turn session_state_changed marker
365
378
  # means the conversation moved on; a ProcessError now is a fresh
366
379
  # crash, not the expected exit from a prior error result. Mirrors
367
380
  # the Python/TypeScript SDK reset logic.
368
- @last_error_result_text = nil
381
+ @last_error_result = nil
369
382
  end
370
383
  # Regular SDK messages go to the queue
371
384
  @message_queue.enqueue(message)
372
385
  end
373
386
  end
374
387
  rescue StandardError => e
375
- # Unblock pending control requests (e.g., initialize) so callers don't
376
- # hang until timeout. INVARIANT: store the result before signaling —
377
- # senders check the slot before waiting (level-trigger).
378
- @pending_control_responses.dup.each do |request_id, condition|
379
- @pending_control_results[request_id] ||= e
380
- condition.signal
381
- end
382
-
383
388
  # When the CLI emits a result with is_error=true (e.g. error_max_turns,
384
- # error_during_execution, a StructuredOutput error) it then exits
385
- # non-zero on purpose, for shell-script consumers. The trailing
386
- # ProcessError carries no information beyond "exit code 1" — replace it
387
- # with the structured error the CLI already reported so the exception is
388
- # actionable. Mirrors the Python SDK (_read_messages) and the TypeScript
389
- # SDK (Query.ts readMessages).
390
- error = if e.is_a?(ProcessError) && @last_error_result_text
391
- ProcessError.new("Claude Code returned an error result: #{@last_error_result_text}",
392
- exit_code: e.exit_code, stderr: e.stderr)
389
+ # error_during_execution, an API failure, a StructuredOutput error) it
390
+ # then exits non-zero on purpose, for shell-script consumers. The
391
+ # trailing ProcessError carries no information beyond "exit code 1" —
392
+ # replace it with a ResultError carrying what the CLI already reported
393
+ # so the exception is actionable *and* typed. Mirrors the Python SDK
394
+ # (_read_messages) and the TypeScript SDK (Query.ts readMessages).
395
+ error = if e.is_a?(ProcessError) && @last_error_result
396
+ ResultError.new("Claude Code returned an error result: #{ResultError.error_text(@last_error_result)}",
397
+ data: @last_error_result, exit_code: e.exit_code, stderr: e.stderr,
398
+ original_error: e)
393
399
  else
394
400
  e
395
401
  end
396
402
 
403
+ # Unblock pending control requests (e.g., initialize) so callers don't
404
+ # hang until timeout. Computed AFTER the replacement above so they get
405
+ # the same enriched error the message stream does: a refused resume (a
406
+ # nonexistent session, a failed --resume-drops-turn guard) is reported
407
+ # by the CLI as an error result followed by exit 1 *before* it answers
408
+ # the SDK's `initialize`, so signaling the raw `e` here handed that
409
+ # in-flight request "Command failed with exit code 1" and discarded the
410
+ # real reason (Python #1198).
411
+ # INVARIANT: store the result before signaling — senders check the slot
412
+ # before waiting (level-trigger).
413
+ @pending_control_responses.dup.each do |request_id, condition|
414
+ @pending_control_results[request_id] ||= error
415
+ condition.signal
416
+ end
417
+
397
418
  # Put error in queue so iterators can handle it
398
419
  @message_queue.enqueue({ type: 'error', error: error })
399
420
  ensure
@@ -459,6 +480,24 @@ module ClaudeAgentSDK
459
480
  end
460
481
  end
461
482
 
483
+ # Whether the CLI may still send control requests that need a reply.
484
+ #
485
+ # SDK MCP servers, hooks and the can_use_tool permission callback are all
486
+ # served over the control protocol: the CLI writes a control_request to
487
+ # stdout and blocks until the SDK writes the matching control_response to
488
+ # stdin. Closing stdin while any of these is configured makes every later
489
+ # request fail CLI-side with "Stream closed". Mirrors the TypeScript
490
+ # SDK's hasBidirectionalNeeds, de-prefixed per Ruby naming (Python #1204).
491
+ #
492
+ # can_use_tool is tested for truthiness, not for nil: ClaudeAgentSDK
493
+ # .configure_can_use_tool treats a falsey callback as "no callback"
494
+ # (`can_use_tool: enabled ? cb : false` is a real config shape), and the
495
+ # two must agree — otherwise `false` would skip the stdio routing while
496
+ # still holding stdin open for a reply that can never be asked for.
497
+ def bidirectional_needs?
498
+ !@sdk_mcp_servers.empty? || !@hooks.empty? || !!@can_use_tool
499
+ end
500
+
462
501
  # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
463
502
  # must never propagate into the read loop or its teardown.
464
503
  def flush_transcript_mirror
@@ -1239,8 +1278,9 @@ module ClaudeAgentSDK
1239
1278
  })
1240
1279
  end
1241
1280
 
1242
- # Wait for a run-ending result before closing stdin when hooks or SDK MCP
1243
- # servers may still need to exchange control messages with the CLI.
1281
+ # Wait for a run-ending result before closing stdin when hooks, SDK MCP
1282
+ # servers or a can_use_tool callback may still need to exchange control
1283
+ # messages with the CLI.
1244
1284
  # The control protocol requires stdin to stay open for the entire turn
1245
1285
  # (hook replies, can_use_tool replies and SDK MCP tool results are all
1246
1286
  # written to stdin), so no timeout is applied — closing stdin mid-turn
@@ -1252,11 +1292,15 @@ module ClaudeAgentSDK
1252
1292
  # another result. The condition is guaranteed to be signaled: by the
1253
1293
  # result branch in read_messages once no tasks are in flight, or by its
1254
1294
  # ensure block when the process exits early.
1295
+ #
1296
+ # Known limitation (same as Python's): the condition is one-shot and is
1297
+ # not aware of prompt messages still queued CLI-side, so an Enumerator
1298
+ # prompt yielding several user messages (several turns) releases the hold
1299
+ # at the first turn boundary with no tracked tasks; control requests from
1300
+ # later turns can then find stdin closed. Single-message and String
1301
+ # prompts — the common one-shot shapes — are fully covered.
1255
1302
  def wait_for_result_and_end_input
1256
- if !@first_result_received &&
1257
- ((@sdk_mcp_servers && !@sdk_mcp_servers.empty?) || (@hooks && !@hooks.empty?))
1258
- @first_result_condition.wait
1259
- end
1303
+ @first_result_condition.wait if !@first_result_received && bidirectional_needs?
1260
1304
  ensure
1261
1305
  @transport.end_input
1262
1306
  end
@@ -5,6 +5,7 @@ require 'fileutils'
5
5
  require 'tmpdir'
6
6
  require 'open3'
7
7
  require 'rbconfig'
8
+ require 'securerandom'
8
9
  require_relative 'fiber_boundary'
9
10
  require_relative 'sessions'
10
11
  require_relative 'session_store'
@@ -36,7 +37,7 @@ module ClaudeAgentSDK
36
37
  # copies, and tell the user where the data is so they can import it into
37
38
  # the store manually. Never raises.
38
39
  def preserve_transcripts
39
- ['.credentials.json', '.claude.json'].each do |name|
40
+ ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
40
41
  FileUtils.rm_f(File.join(@config_dir, name))
41
42
  end
42
43
  warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
@@ -55,6 +56,18 @@ module ClaudeAgentSDK
55
56
  # from the store, writes it to a temp dir laid out like ~/.claude/, and returns
56
57
  # the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
57
58
  module SessionResume # rubocop:disable Metrics/ModuleLength
59
+ # User settings files seeded into the temp config dir. cowork_settings.json
60
+ # is the alternate filename the CLI reads in cowork-plugins mode.
61
+ SEEDED_SETTINGS_FILES = ['settings.json', 'cowork_settings.json'].freeze
62
+
63
+ # User-settings keys that only misbehave under the redirected
64
+ # CLAUDE_CONFIG_DIR: plugin declarations reconcile against the
65
+ # always-empty tmp_base/plugins cache and would network-install each
66
+ # declared marketplace on every resume.
67
+ RESUME_SETTINGS_STRIPPED_KEYS = %w[enabledPlugins extraKnownMarketplaces].freeze
68
+
69
+ UTF8_BOM = "\xEF\xBB\xBF".b.freeze
70
+
58
71
  KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials'
59
72
  KEYCHAIN_TIMEOUT_SECONDS = 5
60
73
 
@@ -269,13 +282,24 @@ module ClaudeAgentSDK
269
282
  chmod_owner_only(path)
270
283
  end
271
284
 
272
- # Copy .credentials.json (refreshToken redacted) and .claude.json from the
273
- # caller's effective config locations so the resumed subprocess can auth.
285
+ # Seed tmp_base with the caller's auth and user config so the resumed
286
+ # subprocess can authenticate: .credentials.json (refreshToken redacted),
287
+ # .claude.json, and user settings.json / cowork_settings.json (plugin
288
+ # declarations stripped).
289
+ #
290
+ # Source resolution mirrors the CLI: .credentials.json, settings.json and
291
+ # cowork_settings.json live under the config dir (default ~/.claude/), while
292
+ # .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
293
+ # ~/.claude.json (NOT ~/.claude/.claude.json).
274
294
  def copy_auth_files(tmp_base, opt_env)
275
295
  caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
276
296
  source_config_dir = caller_config_dir || File.join(Dir.home, '.claude')
277
297
 
278
- creds_json = read_file_if_present(File.join(source_config_dir, '.credentials.json'))
298
+ # read_if_present returns raw bytes; the credentials path parses and
299
+ # re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes
300
+ # simply fail to parse and get written through, as before).
301
+ creds_bytes = read_if_present(File.join(source_config_dir, '.credentials.json'))
302
+ creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8)
279
303
 
280
304
  # macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting
281
305
  # CLAUDE_CONFIG_DIR changes the Keychain service suffix so the subprocess's
@@ -291,6 +315,115 @@ module ClaudeAgentSDK
291
315
 
292
316
  claude_json_src = caller_config_dir ? File.join(caller_config_dir, '.claude.json') : File.join(Dir.home, '.claude.json')
293
317
  copy_if_present(claude_json_src, File.join(tmp_base, '.claude.json'))
318
+
319
+ # User settings carry apiKeyHelper (a fourth auth mechanism alongside
320
+ # .credentials.json / Keychain / env vars) plus the user's env, hooks and
321
+ # permissions. Without them the resumed subprocess sees no user settings
322
+ # at all, and an apiKeyHelper-only host fails with "Not logged in".
323
+ # cowork_settings.json is the alternate filename the CLI reads in
324
+ # cowork-plugins mode. Both pass through strip_settings_for_resume so
325
+ # plugin declarations don't reconcile against the empty tmp_base cache.
326
+ transform = ->(content) { strip_settings_for_resume(content) }
327
+ SEEDED_SETTINGS_FILES.each do |name|
328
+ copy_if_present(File.join(source_config_dir, name), File.join(tmp_base, name), transform)
329
+ end
330
+ end
331
+
332
+ # Drop settings keys that only misbehave under the redirected config dir:
333
+ # RESUME_SETTINGS_STRIPPED_KEYS (plugin declarations, which reconcile
334
+ # against the always-empty tmp_base plugin cache and would network-install
335
+ # every declared marketplace on each resume) and env.CLAUDE_CONFIG_DIR
336
+ # (which would point the subprocess's config reads away from tmp_base).
337
+ # Content that doesn't parse as a JSON object is returned untouched so the
338
+ # subprocess sees exactly what the CLI would have read.
339
+ def strip_settings_for_resume(content)
340
+ parsed, restore = parse_settings_bytes(content)
341
+ return content unless parsed.is_a?(Hash)
342
+
343
+ stripped = false
344
+ RESUME_SETTINGS_STRIPPED_KEYS.each do |key|
345
+ next unless parsed.key?(key)
346
+
347
+ parsed.delete(key)
348
+ stripped = true
349
+ end
350
+ env_block = parsed['env']
351
+ if env_block.is_a?(Hash) && env_block.key?('CLAUDE_CONFIG_DIR')
352
+ env_block.delete('CLAUDE_CONFIG_DIR')
353
+ stripped = true
354
+ end
355
+ return content unless stripped
356
+
357
+ begin
358
+ out = JSON.generate(parsed)
359
+ rescue JSON::GeneratorError
360
+ # A spec-valid overflow like 1e999 parses to Infinity, and JSON.generate
361
+ # refuses to emit the bare token the CLI would reject anyway. Fall back
362
+ # to the original bytes rather than writing something unusable.
363
+ return content
364
+ end
365
+ restore ? restore.call(out) : out
366
+ end
367
+
368
+ # Parse settings bytes tolerating a UTF-8 BOM (PowerShell writes
369
+ # settings.json with one, and JSON.parse rejects it). Returns
370
+ # [parsed, restore] where +restore+ is nil, or a callable that must be
371
+ # applied to the re-serialized output (see mask_surrogate_escapes).
372
+ # Returns [nil, nil] when the bytes aren't valid UTF-8 or aren't valid JSON.
373
+ def parse_settings_bytes(content)
374
+ body = content.b
375
+ body = body.byteslice(3..) || +'' if body.start_with?(UTF8_BOM)
376
+ text = body.dup.force_encoding(Encoding::UTF_8)
377
+ return [nil, nil] unless text.valid_encoding?
378
+
379
+ parsed = begin
380
+ JSON.parse(text)
381
+ rescue JSON::ParserError
382
+ nil
383
+ end
384
+ return [parsed, nil] unless parsed.nil?
385
+
386
+ # Ruby's JSON parser rejects lone surrogate escapes ("\ud800") that
387
+ # Python's accepts, with no option to relax it. Passing the file through
388
+ # unstripped over one would leave enabledPlugins in place and let the
389
+ # resumed CLI keep network-installing every declared marketplace — the
390
+ # exact behavior this seeding exists to prevent. Retry with the surrogate
391
+ # escapes masked out, restoring them verbatim after re-serialization.
392
+ masked, restore = mask_surrogate_escapes(text)
393
+ return [nil, nil] if masked.nil?
394
+
395
+ begin
396
+ [JSON.parse(masked), restore]
397
+ rescue JSON::ParserError
398
+ [nil, nil]
399
+ end
400
+ end
401
+
402
+ # Replace every \uD800-\uDFFF escape with an opaque ASCII token so the
403
+ # document parses, and return [masked_text, restore] where +restore+ maps
404
+ # the tokens in generated output back to the original escape text
405
+ # byte-for-byte. Returns [nil, nil] when there is nothing to mask (the
406
+ # parse failure was something else) or the token could collide.
407
+ def mask_surrogate_escapes(text)
408
+ token_prefix = "CASDKSURROGATE#{SecureRandom.hex(8)}"
409
+ return [nil, nil] if text.include?(token_prefix)
410
+
411
+ escapes = []
412
+ masked = text.gsub(/\\+u[0-9a-fA-F]{4}/) do |match|
413
+ slashes = match[/\A\\+/]
414
+ # An even run is escaped backslashes followed by literal "uXXXX" text,
415
+ # not an escape sequence.
416
+ next match if slashes.length.even?
417
+
418
+ escape = "\\#{match[slashes.length..]}"
419
+ next match unless escape[2..].to_i(16).between?(0xD800, 0xDFFF)
420
+
421
+ escapes << escape
422
+ "#{slashes[0...-1]}#{token_prefix}#{escapes.length - 1}Z"
423
+ end
424
+ return [nil, nil] if escapes.empty?
425
+
426
+ [masked, ->(out) { out.gsub(/#{Regexp.escape(token_prefix)}(\d+)Z/) { escapes[::Regexp.last_match(1).to_i] } }]
294
427
  end
295
428
 
296
429
  # Write creds_json with claudeAiOauth.refreshToken removed. The resumed
@@ -298,24 +431,48 @@ module ClaudeAgentSDK
298
431
  # single-use refresh token would be consumed and the new tokens written
299
432
  # somewhere the parent never reads — revoking the parent's creds. Stripping
300
433
  # refreshToken short-circuits the subprocess's refresh check.
434
+ #
435
+ # Content that isn't JSON at all is written through unchanged: the
436
+ # subprocess then reads exactly what the CLI would have read (and fails on
437
+ # it the same way). Note this branch is narrower than "unreadable content" —
438
+ # JSON.parse accepts illegal UTF-8 bytes inside an otherwise well-formed
439
+ # document, so redaction can instead fail at re-serialization time; see
440
+ # below for why that case writes nothing rather than writing through.
301
441
  def write_redacted_credentials(creds_json, dst)
302
- return if creds_json.nil?
442
+ out = redacted_credentials(creds_json)
443
+ return if out.nil?
303
444
 
304
- out = creds_json
305
- begin
306
- data = JSON.parse(creds_json)
307
- oauth = data.is_a?(Hash) ? data['claudeAiOauth'] : nil
308
- if oauth.is_a?(Hash) && oauth.key?('refreshToken')
309
- oauth.delete('refreshToken')
310
- out = JSON.generate(data)
311
- end
312
- rescue JSON::ParserError
313
- # Unparseable — write through; the subprocess will fail to parse it too.
314
- end
315
445
  File.write(dst, out)
316
446
  chmod_owner_only(dst)
317
447
  end
318
448
 
449
+ # The redaction itself: returns the bytes to write, or nil to write no
450
+ # credentials file at all.
451
+ def redacted_credentials(creds_json)
452
+ return nil if creds_json.nil?
453
+
454
+ data = JSON.parse(creds_json)
455
+ oauth = data.is_a?(Hash) ? data['claudeAiOauth'] : nil
456
+ return creds_json unless oauth.is_a?(Hash) && oauth.key?('refreshToken')
457
+
458
+ oauth.delete('refreshToken')
459
+ begin
460
+ JSON.generate(data)
461
+ rescue JSON::GeneratorError => e
462
+ # The redaction succeeded but the result can't be re-emitted (illegal
463
+ # UTF-8 elsewhere in the file). Writing the original bytes through
464
+ # would put the un-redacted refreshToken back into the temp dir, so
465
+ # seed no credentials file instead — and don't abort the resume over
466
+ # it either, matching every other seed file's policy. A resume that
467
+ # falls back to another auth mechanism beats one that lets the
468
+ # subprocess burn the parent's single-use refresh token.
469
+ warn "Claude SDK: [SessionStore] resume: cannot redact credentials (#{e.message}); seeding none"
470
+ nil
471
+ end
472
+ rescue JSON::ParserError
473
+ creds_json
474
+ end
475
+
319
476
  # Read OAuth credentials JSON from the macOS Keychain (default service name).
320
477
  # Best-effort — returns nil on any error or non-macOS platforms.
321
478
  def read_keychain_credentials
@@ -404,16 +561,17 @@ module ClaudeAgentSDK
404
561
  # Partition entries into transcript vs agent_metadata and write the
405
562
  # <subpath>.jsonl transcript and, if present, the <subpath>.meta.json sidecar.
406
563
  def write_subagent_files(session_dir, subpath, entries)
407
- metadata, transcript = entries.partition { |e| e.is_a?(Hash) && e['type'] == 'agent_metadata' }
564
+ # Last metadata entry wins (see Sessions.split_agent_metadata).
565
+ metadata, transcript = Sessions.split_agent_metadata(entries)
408
566
  sub_file = File.join(session_dir, "#{subpath}.jsonl")
409
567
 
410
568
  write_jsonl(sub_file, transcript) unless transcript.empty?
411
569
 
412
- return if metadata.empty?
570
+ return if metadata.nil?
413
571
 
414
- # Last metadata entry wins; strip the synthetic type field.
415
- meta_content = metadata.last.except('type')
416
- meta_file = "#{sub_file.delete_suffix('.jsonl')}.meta.json"
572
+ # Strip the synthetic type field.
573
+ meta_content = metadata.except('type')
574
+ meta_file = Sessions.agent_metadata_sidecar_path(sub_file)
417
575
  FileUtils.mkdir_p(File.dirname(meta_file))
418
576
  File.write(meta_file, JSON.generate(meta_content))
419
577
  chmod_owner_only(meta_file)
@@ -466,9 +624,26 @@ module ClaudeAgentSDK
466
624
  FileUtils.rm_rf(path)
467
625
  end
468
626
 
469
- def read_file_if_present(path)
470
- File.read(path)
471
- rescue SystemCallError
627
+ # Read a regular file's bytes, or return nil.
628
+ #
629
+ # A missing source is skipped silently. Any other reason it can't be read
630
+ # (EACCES, a directory or FIFO where a file was expected, ...) is logged and
631
+ # skipped: these files are best-effort enrichment of the temp config dir, so
632
+ # an unreadable one must not abort — or, for a FIFO, hang — the resume. The
633
+ # ftype check happens on the stat, before any open: opening a FIFO blocks
634
+ # forever, so it must never be opened in the first place.
635
+ def read_if_present(path)
636
+ ftype = File.stat(path).ftype
637
+ unless ftype == 'file'
638
+ warn "Claude SDK: [SessionStore] resume: skipping #{path} (not a regular file: #{ftype})"
639
+ return nil
640
+ end
641
+
642
+ File.binread(path)
643
+ rescue Errno::ENOENT
644
+ nil
645
+ rescue SystemCallError => e
646
+ warn "Claude SDK: [SessionStore] resume: skipping #{path} (#{e.message})"
472
647
  nil
473
648
  end
474
649
 
@@ -482,15 +657,28 @@ module ClaudeAgentSDK
482
657
  nil
483
658
  end
484
659
 
485
- # Copy src to dst (locked to 0600) when src exists; no-op otherwise. The
486
- # only caller copies .claude.json, which can hold MCP-header secrets and
487
- # customApiKeyResponses, so it gets the same owner-only mode as the other
660
+ # Copy src to dst (locked to 0600) when src exists, through an optional
661
+ # transform; no-op otherwise. Callers copy .claude.json and the user
662
+ # settings files, which can hold MCP-header secrets, customApiKeyResponses
663
+ # and apiKeyHelper env, so they get the same owner-only mode as the other
488
664
  # materialized files rather than inheriting the source's (often 0644) mode.
489
- def copy_if_present(src, dst)
490
- FileUtils.copy_file(src, dst)
491
- chmod_owner_only(dst)
492
- rescue SystemCallError
493
- nil
665
+ # See read_if_present for the skip policy.
666
+ def copy_if_present(src, dst, transform = nil)
667
+ content = read_if_present(src)
668
+ return if content.nil?
669
+
670
+ begin
671
+ File.binwrite(dst, transform ? transform.call(content) : content)
672
+ chmod_owner_only(dst)
673
+ rescue SystemCallError => e
674
+ # Don't leave a truncated dst behind for the subprocess to misparse.
675
+ begin
676
+ FileUtils.rm_f(dst)
677
+ rescue SystemCallError
678
+ nil
679
+ end
680
+ warn "Claude SDK: [SessionStore] resume: skipping #{src} (#{e.message})"
681
+ end
494
682
  end
495
683
 
496
684
  # Resolve the value the CHILD process will see for env var +name+. Presence
@@ -513,6 +701,8 @@ module ClaudeAgentSDK
513
701
  private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
514
702
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
515
703
  :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
516
- :resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
704
+ :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
705
+ :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
706
+ :redacted_credentials
517
707
  end
518
708
  end