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.
@@ -30,16 +30,24 @@ module ClaudeAgentSDK
30
30
  end
31
31
  end
32
32
 
33
- # A single message from a session transcript
33
+ # A single message from a session transcript.
34
+ #
35
+ # +parent_tool_use_id+ / +parent_agent_id+ are only populated for messages
36
+ # returned by get_subagent_messages / get_subagent_messages_from_store:
37
+ # respectively the id of the Agent tool_use block in the parent session that
38
+ # spawned the subagent, and (for nested subagents) the agent id of the
39
+ # subagent that spawned it. Both are nil when the subagent's metadata is
40
+ # unavailable, and always nil for top-level session messages.
34
41
  class SessionMessage
35
- attr_accessor :type, :uuid, :session_id, :message, :parent_tool_use_id
42
+ attr_accessor :type, :uuid, :session_id, :message, :parent_tool_use_id, :parent_agent_id
36
43
 
37
- def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil)
44
+ def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil, parent_agent_id: nil)
38
45
  @type = type
39
46
  @uuid = uuid
40
47
  @session_id = session_id
41
48
  @message = message
42
49
  @parent_tool_use_id = parent_tool_use_id
50
+ @parent_agent_id = parent_agent_id
43
51
  end
44
52
 
45
53
  # Concatenated text across every TextBlock in this message.
@@ -614,7 +622,49 @@ module ClaudeAgentSDK
614
622
  # `except OSError: return []`).
615
623
  return []
616
624
  end
617
- entries_to_subagent_messages(entries, limit, offset)
625
+
626
+ # The .meta.json sidecar next to the transcript records which Agent
627
+ # tool_use spawned this subagent (and, for nested subagents, the parent
628
+ # agent id). Like the transcript read above this is best-effort: any
629
+ # failure to read it degrades to "no metadata" rather than raising.
630
+ meta = begin
631
+ read_agent_metadata_sidecar(path)
632
+ rescue SystemCallError
633
+ nil
634
+ end
635
+ parent_tool_use_id, parent_agent_id = parent_ids_from_agent_metadata(meta)
636
+
637
+ entries_to_subagent_messages(entries, limit, offset, parent_tool_use_id, parent_agent_id)
638
+ end
639
+
640
+ # agent-<id>.jsonl -> agent-<id>.meta.json in the same directory. The single
641
+ # definition of the sidecar naming convention, shared by the disk read path,
642
+ # session import, and resume materialization.
643
+ # @param transcript_path [String] Path to the subagent .jsonl transcript
644
+ # @return [String] Path to the sidecar
645
+ def agent_metadata_sidecar_path(transcript_path)
646
+ "#{transcript_path.delete_suffix('.jsonl')}.meta.json"
647
+ end
648
+
649
+ # Separate the synthetic agent_metadata entry from transcript lines.
650
+ #
651
+ # A subagent's SessionStore stream carries its .meta.json sidecar as
652
+ # { 'type' => 'agent_metadata', ... } entries alongside the transcript.
653
+ # Returns [metadata, transcript] where metadata is the LAST such entry (it
654
+ # is rewritten on resume, so last wins) or nil.
655
+ # @param entries [Array] Raw store/transcript entries
656
+ # @return [Array(Hash, Array)] [metadata_or_nil, transcript_entries]
657
+ def split_agent_metadata(entries)
658
+ metadata = nil
659
+ transcript = []
660
+ entries.each do |e|
661
+ if e.is_a?(Hash) && e['type'] == 'agent_metadata'
662
+ metadata = e
663
+ else
664
+ transcript << e
665
+ end
666
+ end
667
+ [metadata, transcript]
618
668
  end
619
669
 
620
670
  # ---- SessionStore-backed reads (store counterparts to the disk readers) ----
@@ -725,12 +775,15 @@ module ClaudeAgentSDK
725
775
  entries = session_store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
726
776
  return [] if entries.nil? || entries.empty?
727
777
 
728
- # Drop synthetic agent_metadata entries (they describe the .meta.json
729
- # sidecar, not transcript lines).
730
- transcript = entries.reject { |e| e.is_a?(Hash) && e['type'] == 'agent_metadata' }
778
+ # The synthetic agent_metadata entry (the store's copy of the .meta.json
779
+ # sidecar) records which Agent tool_use spawned this subagent. Recover the
780
+ # parent ids from it, then drop it: it is not a transcript line.
781
+ meta_entry, transcript = split_agent_metadata(entries)
731
782
  return [] if transcript.empty?
732
783
 
733
- entries_to_subagent_messages(filter_transcript_entries(transcript), limit, offset)
784
+ parent_tool_use_id, parent_agent_id = parent_ids_from_agent_metadata(meta_entry)
785
+ entries_to_subagent_messages(filter_transcript_entries(transcript), limit, offset,
786
+ parent_tool_use_id, parent_agent_id)
734
787
  end
735
788
 
736
789
  # Replay a local on-disk session transcript into a SessionStore (inverse of
@@ -908,7 +961,8 @@ module ClaudeAgentSDK
908
961
  # filter_visible_messages drops sidechain entries) would return [] for
909
962
  # every real subagent transcript. Mirrors Python's
910
963
  # _entries_to_subagent_messages: type-only filter, no flag rejection.
911
- def entries_to_subagent_messages(entries, limit, offset)
964
+ # Every message in one subagent transcript shares the same parent ids.
965
+ def entries_to_subagent_messages(entries, limit, offset, parent_tool_use_id = nil, parent_agent_id = nil)
912
966
  offset ||= 0
913
967
  messages = build_subagent_chain(entries).filter_map do |entry|
914
968
  next unless %w[user assistant].include?(entry['type'])
@@ -917,7 +971,9 @@ module ClaudeAgentSDK
917
971
  type: entry['type'],
918
972
  uuid: entry['uuid'],
919
973
  session_id: entry['sessionId'] || entry['session_id'] || '',
920
- message: entry['message']
974
+ message: entry['message'],
975
+ parent_tool_use_id: parent_tool_use_id,
976
+ parent_agent_id: parent_agent_id
921
977
  )
922
978
  end
923
979
  messages = messages[offset..] || []
@@ -966,20 +1022,56 @@ module ClaudeAgentSDK
966
1022
  sub_key = { 'project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath }
967
1023
  append_jsonl_file_in_batches(file_path, sub_key, store, batch_size)
968
1024
 
969
- meta_text = begin
970
- File.read("#{file_path.delete_suffix('.jsonl')}.meta.json", encoding: 'UTF-8')
971
- rescue Errno::ENOENT
972
- nil
973
- end
974
- next if meta_text.nil?
1025
+ # A missing, corrupt, or non-object sidecar is treated as absent (the
1026
+ # transcript is still imported); other IO errors propagate.
1027
+ meta = read_agent_metadata_sidecar(file_path)
1028
+ next if meta.nil?
975
1029
 
976
- meta = JSON.parse(meta_text)
977
1030
  # Synthetic 'agent_metadata' marker must always win so a future meta key
978
1031
  # named 'type' can't reclassify the sidecar as a transcript line on resume.
979
- store.append(sub_key, [meta.merge('type' => 'agent_metadata')]) if meta.is_a?(Hash)
1032
+ store.append(sub_key, [meta.merge('type' => 'agent_metadata')])
980
1033
  end
981
1034
  end
982
1035
 
1036
+ # Read the .meta.json sidecar beside a subagent transcript. Returns nil when
1037
+ # the sidecar is missing, is not a regular file, is not valid UTF-8, is not
1038
+ # valid JSON, or is not a JSON object — an unusable optional sidecar
1039
+ # degrades to an absent one. Other IO errors (EACCES, ...) propagate;
1040
+ # callers that need a best-effort read rescue them.
1041
+ def read_agent_metadata_sidecar(transcript_path)
1042
+ path = agent_metadata_sidecar_path(transcript_path)
1043
+ # Check the type on the stat, before any open: opening a FIFO with no
1044
+ # writer blocks forever, and this optional read would hang the caller
1045
+ # with no exception for its best-effort rescue to catch.
1046
+ return nil unless File.stat(path).ftype == 'file'
1047
+
1048
+ text = File.read(path, encoding: 'UTF-8')
1049
+ # JSON.parse is lenient about illegal bytes inside an otherwise
1050
+ # well-formed UTF-8-tagged document: it returns a Hash holding
1051
+ # invalidly-encoded values that only blow up later, at JSON.generate
1052
+ # time. Session import would persist such a Hash as an agent_metadata
1053
+ # entry, and every subsequent resume through that store would then die
1054
+ # re-serializing the sidecar. Treat unusable bytes as an absent sidecar.
1055
+ return nil unless text.valid_encoding?
1056
+
1057
+ meta = JSON.parse(text)
1058
+ meta.is_a?(Hash) ? meta : nil
1059
+ rescue Errno::ENOENT, JSON::ParserError
1060
+ nil
1061
+ end
1062
+
1063
+ # Extract [toolUseId, parentAgentId] from an agent metadata hash, narrowing
1064
+ # both to String. Works for the on-disk .meta.json sidecar and for the
1065
+ # synthetic agent_metadata entry a SessionStore receives in its place.
1066
+ def parent_ids_from_agent_metadata(meta)
1067
+ return [nil, nil] unless meta.is_a?(Hash)
1068
+
1069
+ tool_use_id = meta['toolUseId']
1070
+ parent_agent_id = meta['parentAgentId']
1071
+ [tool_use_id.is_a?(String) ? tool_use_id : nil,
1072
+ parent_agent_id.is_a?(String) ? parent_agent_id : nil]
1073
+ end
1074
+
983
1075
  def append_jsonl_file_in_batches(file_path, key, store, batch_size)
984
1076
  batch = []
985
1077
  nbytes = 0
@@ -1357,7 +1449,8 @@ module ClaudeAgentSDK
1357
1449
  :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
1358
1450
  :filter_transcript_entries, :entries_to_messages,
1359
1451
  :entries_to_subagent_messages, :build_subagent_chain, :resolve_subagent_subpath,
1360
- :import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files
1452
+ :import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files,
1453
+ :read_agent_metadata_sidecar, :parent_ids_from_agent_metadata
1361
1454
 
1362
1455
  # These remain accessible for SessionMutations:
1363
1456
  # config_dir, sanitize_path, find_project_dir, detect_worktrees
@@ -212,6 +212,64 @@ module ClaudeAgentSDK
212
212
  class UserMessage < Type
213
213
  attr_accessor :content, :uuid, :parent_tool_use_id, :tool_use_result
214
214
 
215
+ # Provenance of this message — where the turn came from.
216
+ #
217
+ # In streaming-input mode a single connection interleaves the turns you
218
+ # send with turns the session injects on its own (background-task
219
+ # notifications, fired scheduled-task prompts, MCP channel messages,
220
+ # messages relayed from peer sessions, ...). `origin` tells them apart —
221
+ # see {ResultMessage#origin} for deciding whether a result answers *your*
222
+ # prompt.
223
+ #
224
+ # **Key form — read this before indexing into it.** A plain Hash, passed
225
+ # through from the CLI untouched: the SDK does not model it, whitelist its
226
+ # keys, or rewrite them, so kinds and fields newer CLI versions add stay
227
+ # visible. Keys therefore follow the transport's JSON parsing, which uses
228
+ # `symbolize_names: true` — they are **Symbols with the wire spelling
229
+ # preserved**, so camelCase keys stay camelCase and you index with
230
+ # `origin[:kind]`, `origin[:fromSession]`, `origin[:senderTaskId]`,
231
+ # `origin[:verifiedPeerPid]`. This is unlike the snake_case attributes
232
+ # elsewhere in this SDK, and unlike the Python SDK's string keys: a
233
+ # `origin["kind"]` or `origin[:from_session]` lookup silently returns nil
234
+ # and makes every attributed turn look unattributed. Only `:kind` is
235
+ # guaranteed present; the rest depend on it.
236
+ #
237
+ # `nil` means the CLI did not attribute the message — that is the normal
238
+ # case for prompts you send through {ClaudeAgentSDK.query} / {Client#query},
239
+ # unless the host stamps `origin: { kind: 'human' }` on the message Hash
240
+ # itself (only the `human` kind is honored from an SDK host). Populated on
241
+ # injected turns (task notifications, channel/peer messages, ...) and on
242
+ # user messages the CLI replays; tool-result messages never carry it.
243
+ #
244
+ # Known `:kind` values — documentation, not validation; treat anything
245
+ # unrecognized as "not human":
246
+ #
247
+ # - `'human'` — a turn submitted by the SDK host
248
+ # - `'channel'` — arrived on an MCP channel; `:server` names the MCP server
249
+ # - `'peer'` — relayed from a peer session. `:from` (sender address,
250
+ # sender-asserted — for reply routing or display, never as proof of
251
+ # identity), `:name` (display name, already normalized by the CLI),
252
+ # `:fromSession` (the sender's host-openable session id, a navigation
253
+ # target only), `:senderTaskId` (task id of the in-process background
254
+ # subagent that sent it; absent for cross-session peers), `:body`
255
+ # (decoded message body with the peer envelope stripped, byte-exact with
256
+ # what the model saw — render this instead of re-parsing the message
257
+ # text), `:verifiedPeerPid` (kernel-verified pid of the process that
258
+ # connected to this session's local messaging socket — the *connecting*
259
+ # process, which for relayed traffic is the relay; absent when
260
+ # unverifiable)
261
+ # - `'task-notification'` — a background task's delivery. `:subkind` is
262
+ # `'scheduled-trigger'` (the fired prompt of a scheduled task) or
263
+ # `'peer-send-message'` (a message sent from another of the user's
264
+ # sessions); absent for ordinary background-task notifications
265
+ # - `'coordinator'`, `'unclassified'`, `'observer'` (`:from` /
266
+ # `:senderTaskId` as for `peer`), `'auto-continuation'`,
267
+ # `'observer-activity'`
268
+ #
269
+ # @return [Hash{Symbol => Object}, nil]
270
+ # @see ResultMessage#origin
271
+ attr_accessor :origin
272
+
215
273
  # Concatenated text of this message. Handles both String content
216
274
  # (plain-text user prompt) and Array-of-blocks content (typed content).
217
275
  # Returns "" when there is no text.
@@ -442,6 +500,32 @@ module ClaudeAgentSDK
442
500
  def deferred_tool_use=(value)
443
501
  @deferred_tool_use = value.is_a?(Hash) ? DeferredToolUse.from_hash(value) : value
444
502
  end
503
+
504
+ # Provenance of the user message that triggered this turn — `nil` when the
505
+ # CLI did not attribute it. Lets a streaming-input consumer distinguish the
506
+ # result of its own prompt from the result of a turn the session injected
507
+ # on its own:
508
+ #
509
+ # if result.origin.nil? || result.origin[:kind] == 'human'
510
+ # # a turn this application submitted
511
+ # elsif result.origin[:kind] == 'task-notification'
512
+ # # follow-up turn driven by a background task
513
+ # end
514
+ #
515
+ # **Key form.** A plain Hash passed through from the CLI untouched, so its
516
+ # keys are **Symbols with the wire spelling preserved** — camelCase stays
517
+ # camelCase (`origin[:kind]`, `origin[:fromSession]`,
518
+ # `origin[:verifiedPeerPid]`), unlike the snake_case attributes elsewhere
519
+ # in this SDK and unlike the Python SDK's string keys. Indexing with
520
+ # `origin["kind"]` silently returns nil and makes every attributed turn
521
+ # look unattributed.
522
+ #
523
+ # See {UserMessage#origin} for the full list of known `:kind` values and
524
+ # their per-kind keys.
525
+ #
526
+ # @return [Hash{Symbol => Object}, nil]
527
+ # @see UserMessage#origin
528
+ attr_accessor :origin
445
529
  end
446
530
 
447
531
  # Stream event for partial message updates
@@ -507,6 +591,33 @@ module ClaudeAgentSDK
507
591
  end
508
592
  end
509
593
 
594
+ # Emitted when the session's conversation is replaced without ending the
595
+ # connection — e.g. after `/clear` or any other flow that discards the
596
+ # transcript mid-session (type: 'conversation_reset').
597
+ #
598
+ # In streaming-input mode a single connection carries many user turns, and a
599
+ # reset clears the conversation history *and* zeroes the running totals
600
+ # reported on subsequent {ResultMessage} objects (e.g. `total_cost_usd`). If
601
+ # you accumulate those totals across a long-lived session, snapshot them when
602
+ # this message arrives.
603
+ #
604
+ # @!attribute [rw] new_conversation_id
605
+ # Opaque identifier for the fresh conversation, for UIs to key an empty
606
+ # transcript on (and to discard any cached session title). This is *not*
607
+ # the `session_id` of subsequent messages — read that from the next
608
+ # message.
609
+ # @return [String]
610
+ # @!attribute [rw] uuid
611
+ # Unique ID of this message.
612
+ # @return [String]
613
+ # @!attribute [rw] session_id
614
+ # ID of the session that was reset (the outgoing session; messages after
615
+ # the reset carry a new `session_id`).
616
+ # @return [String]
617
+ class ConversationResetMessage < Type
618
+ attr_accessor :new_conversation_id, :uuid, :session_id
619
+ end
620
+
510
621
  # Thinking configuration types
511
622
  #
512
623
  # `display` controls how thinking content appears in responses. Valid values
@@ -1529,10 +1640,22 @@ module ClaudeAgentSDK
1529
1640
  end
1530
1641
  end
1531
1642
 
1532
- # System prompt preset configuration
1643
+ # System prompt preset configuration.
1644
+ #
1645
+ # +snapshot+ controls whether the session keeps the system prompt it
1646
+ # recorded on its first request. When true, every later request (including
1647
+ # after resume) sends the recorded prompt, so a changed +append+ has no
1648
+ # effect until the session is compacted or a new session starts. When
1649
+ # false, the prompt is rebuilt on every request — useful while iterating on
1650
+ # +append+ text across calls that resume the same session. When nil
1651
+ # (omitted), the CLI treats it as true, except in bare mode (+--bare+),
1652
+ # where it acts as false. Sent on the control-protocol +initialize+ request
1653
+ # (never as a CLI flag); requires Claude Code CLI 2.1.257 or later, and
1654
+ # before 2.1.265 a session with an +append+ prompt recorded it only when
1655
+ # +snapshot+ was true. Older CLIs silently ignore it.
1533
1656
  class SystemPromptPreset < Type
1534
1657
  attr_reader :type
1535
- attr_accessor :preset, :append, :exclude_dynamic_sections
1658
+ attr_accessor :preset, :append, :exclude_dynamic_sections, :snapshot
1536
1659
 
1537
1660
  def initialize(attributes = {})
1538
1661
  super
@@ -1543,6 +1666,27 @@ module ClaudeAgentSDK
1543
1666
  result = { type: @type, preset: @preset }
1544
1667
  result[:append] = @append if @append
1545
1668
  result[:exclude_dynamic_sections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
1669
+ result[:snapshot] = @snapshot unless @snapshot.nil?
1670
+ result
1671
+ end
1672
+ end
1673
+
1674
+ # Custom system prompt configuration — the object form of passing a String
1675
+ # as +system_prompt+. Reaches the CLI the same way a String does
1676
+ # (+--system-prompt <prompt>+); the object form exists so +snapshot+ can be
1677
+ # set alongside it (see SystemPromptPreset#snapshot for its semantics).
1678
+ class SystemPromptCustom < Type
1679
+ attr_reader :type
1680
+ attr_accessor :prompt, :snapshot
1681
+
1682
+ def initialize(attributes = {})
1683
+ super
1684
+ @type = 'custom'
1685
+ end
1686
+
1687
+ def to_h
1688
+ result = { type: @type, prompt: @prompt }
1689
+ result[:snapshot] = @snapshot unless @snapshot.nil?
1546
1690
  result
1547
1691
  end
1548
1692
  end
@@ -1580,6 +1724,42 @@ module ClaudeAgentSDK
1580
1724
  :include_hook_events, :strict_mcp_config,
1581
1725
  :callback_scheduling, :callback_wrapper
1582
1726
 
1727
+ # With {#resume_session_at}: the UUID of the user prompt whose turn this
1728
+ # truncating resume intends to discard.
1729
+ #
1730
+ # When set, the CLI validates at load time that every transcript entry
1731
+ # after the `resume_session_at` point is attributable to that turn, and
1732
+ # refuses the resume otherwise — e.g. when the discarded range contains a
1733
+ # queued user message or task notification the session absorbed mid-turn
1734
+ # that the caller had not yet observed. Leave unset to keep the
1735
+ # unvalidated truncation behavior.
1736
+ #
1737
+ # **Choosing the fork point.** Set `resume_session_at` to the *last*
1738
+ # transcript entry of the turn you are keeping — whatever its type — and
1739
+ # `resume_drops_turn` to the prompt UUID of the turn immediately after it
1740
+ # (e.g. the next `SessionMessage` of `type == "user"` from
1741
+ # {ClaudeAgentSDK.get_session_messages}, or the `uuid` you supplied on a
1742
+ # streamed user message). Note that with structured output
1743
+ # ({#output_format}) or end-turn MCP tools a kept turn ends on entries
1744
+ # *after* its last assistant message, so forking at the assistant UUID is
1745
+ # refused by design.
1746
+ #
1747
+ # **On refusal.** The CLI reports an `error_during_execution` result whose
1748
+ # message starts with `Resume rejected by --resume-drops-turn:` — match on
1749
+ # that text. Treat it as deterministic: clear the pending fork target and
1750
+ # resume plainly rather than retrying the same request.
1751
+ #
1752
+ # Forwarded whenever it is not `nil`. An empty string reaches the CLI and
1753
+ # is rejected there as a malformed declaration rather than being dropped by
1754
+ # the SDK, which would silently disarm the guard you believe is armed. The
1755
+ # SDK does not validate the option combination (`resume` /
1756
+ # `resume_session_at`); like the TypeScript and Python SDKs that is the
1757
+ # CLI's call.
1758
+ #
1759
+ # @return [String, nil]
1760
+ # @see #resume_session_at
1761
+ attr_accessor :resume_drops_turn
1762
+
1583
1763
  def initialize(attributes = {})
1584
1764
  self.fork_session = false
1585
1765
  self.continue_conversation = false
@@ -1587,6 +1767,7 @@ module ClaudeAgentSDK
1587
1767
  self.enable_file_checkpointing = false
1588
1768
  self.include_hook_events = false
1589
1769
  self.strict_mcp_config = false
1770
+ self.forward_subagent_text = false
1590
1771
 
1591
1772
  super(merge_with_defaults(attributes || {}))
1592
1773
 
@@ -1675,6 +1856,38 @@ module ClaudeAgentSDK
1675
1856
  @strict_mcp_config = coerce_boolean(value)
1676
1857
  end
1677
1858
 
1859
+ # Forward subagent text and thinking blocks as messages in the stream.
1860
+ # Defaults to `false`.
1861
+ #
1862
+ # By default only `tool_use` / `tool_result` blocks from subagents
1863
+ # (spawned via the Agent tool) are emitted, as {AssistantMessage} /
1864
+ # {UserMessage} objects whose `parent_tool_use_id` is the spawning Agent
1865
+ # `tool_use` id — enough for a progress heartbeat. When true, the
1866
+ # subagent's text and thinking blocks are forwarded the same way, so
1867
+ # consumers can render the full nested transcript. Matches the TypeScript
1868
+ # SDK's `forwardSubagentText`.
1869
+ #
1870
+ # Sent as the `forwardSubagentText` initialize capability rather than a CLI
1871
+ # flag, and only when enabled, so an older CLI never sees an unknown key on
1872
+ # the common path. Both {ClaudeAgentSDK.query} and {Client} run the control
1873
+ # protocol, so the option applies to either entry point.
1874
+ #
1875
+ # Assigning coerces to a Boolean; {#forward_subagent_text?} is the
1876
+ # predicate form.
1877
+ #
1878
+ # @return [Boolean]
1879
+ attr_reader :forward_subagent_text
1880
+
1881
+ # @return [Boolean] {#forward_subagent_text}, as a strict Boolean.
1882
+ def forward_subagent_text?
1883
+ !!forward_subagent_text
1884
+ end
1885
+
1886
+ # @see #forward_subagent_text
1887
+ def forward_subagent_text=(value)
1888
+ @forward_subagent_text = coerce_boolean(value)
1889
+ end
1890
+
1678
1891
  CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
1679
1892
 
1680
1893
  # Where user callbacks (hooks, can_use_tool, SDK MCP handlers, message
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.30.0'
4
+ VERSION = '0.32.0'
5
5
  end
@@ -64,6 +64,34 @@ module ClaudeAgentSDK
64
64
  servers
65
65
  end
66
66
 
67
+ # Internal: validate can_use_tool and route permission prompts over stdio.
68
+ #
69
+ # Shared by query() and Client#connect so both entry points enforce the
70
+ # same rules. Returns options unchanged when no callback is set; otherwise
71
+ # checks it is not combined with permission_prompt_tool_name, emits the
72
+ # shadowing advisory, and returns a copy with permission_prompt_tool_name
73
+ # set to 'stdio' so the CLI sends permission requests over the control
74
+ # protocol.
75
+ #
76
+ # A String prompt is fine here: the SDK is always streaming internally (a
77
+ # String is written to stdin as a user message like any other), so as long
78
+ # as stdin stays open for the turn — which Query#bidirectional_needs? now
79
+ # guarantees for can_use_tool — the permission round-trip works. The old
80
+ # "requires streaming mode" ArgumentError was a needless restriction
81
+ # (Python #1204).
82
+ def self.configure_can_use_tool(options)
83
+ return options unless options.can_use_tool
84
+
85
+ # can_use_tool and permission_prompt_tool_name are mutually exclusive
86
+ raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name' if options.permission_prompt_tool_name
87
+
88
+ # Advisory: warn if other options shadow the callback. After the
89
+ # ArgumentError above so invalid configs raise, not warn.
90
+ OptionWarnings.warn_if_can_use_tool_shadowed(options)
91
+
92
+ options.dup_with(permission_prompt_tool_name: 'stdio')
93
+ end
94
+
67
95
  # Internal: pull exclude_dynamic_sections out of a preset system prompt for
68
96
  # the initialize request (older CLIs ignore unknown initialize fields).
69
97
  # Shared by Client#connect and the one-shot query() path.
@@ -81,6 +109,26 @@ module ClaudeAgentSDK
81
109
  nil
82
110
  end
83
111
 
112
+ # Internal: pull snapshot out of a preset or custom system prompt for the
113
+ # initialize request (older CLIs ignore unknown initialize fields). A
114
+ # String or file prompt has no snapshot, and only a genuine true/false is
115
+ # forwarded — `snapshot: false` is the primary use case, so the Hash lookup
116
+ # must not collapse it to nil. Shared by Client#connect and query().
117
+ def self.extract_system_prompt_snapshot(system_prompt)
118
+ case system_prompt
119
+ when SystemPromptPreset, SystemPromptCustom
120
+ snapshot = system_prompt.snapshot
121
+ return snapshot if [true, false].include?(snapshot)
122
+ when Hash
123
+ type = system_prompt[:type] || system_prompt['type']
124
+ if %w[preset custom].include?(type)
125
+ snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] }
126
+ return snapshot if [true, false].include?(snapshot)
127
+ end
128
+ end
129
+ nil
130
+ end
131
+
84
132
  # Safely call a method on each observer, suppressing any errors.
85
133
  # Each observer is invoked through FiberBoundary so that user code runs
86
134
  # on a plain thread (no Fiber scheduler) even when called from inside
@@ -448,21 +496,7 @@ module ClaudeAgentSDK
448
496
 
449
497
  options ||= ClaudeAgentOptions.new
450
498
 
451
- configured_options = options
452
- if options.can_use_tool
453
- if prompt.is_a?(String)
454
- raise ArgumentError,
455
- 'can_use_tool callback requires streaming mode. Please provide prompt as an Enumerator instead of a String.'
456
- end
457
-
458
- raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name' if options.permission_prompt_tool_name
459
-
460
- configured_options = options.dup_with(permission_prompt_tool_name: 'stdio')
461
- end
462
-
463
- # Advisory: warn if other options shadow the can_use_tool callback.
464
- # After the ArgumentError validations so invalid configs raise, not warn.
465
- OptionWarnings.warn_if_can_use_tool_shadowed(options)
499
+ configured_options = ClaudeAgentSDK.configure_can_use_tool(options)
466
500
 
467
501
  # Fail fast on invalid session_store combinations before spawning the CLI.
468
502
  SessionStores.validate_session_store_options(configured_options)
@@ -535,7 +569,9 @@ module ClaudeAgentSDK
535
569
  agents: configured_options.agents,
536
570
  sdk_mcp_servers: sdk_mcp_servers,
537
571
  exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
572
+ system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt),
538
573
  skills: configured_options.skills,
574
+ forward_subagent_text: configured_options.forward_subagent_text?,
539
575
  callback_scheduling: callback_scheduling,
540
576
  callback_wrapper: callback_wrapper
541
577
  )
@@ -742,18 +778,7 @@ module ClaudeAgentSDK
742
778
  raise ArgumentError, "prompt must be a String, an Enumerator, or nil (got #{prompt.class})" unless prompt.nil? || prompt.is_a?(String) || prompt.respond_to?(:each)
743
779
 
744
780
  # Validate and configure permission settings
745
- configured_options = @options
746
- if @options.can_use_tool
747
- # can_use_tool and permission_prompt_tool_name are mutually exclusive
748
- raise ArgumentError, 'can_use_tool callback cannot be used with permission_prompt_tool_name' if @options.permission_prompt_tool_name
749
-
750
- # Set permission_prompt_tool_name to stdio for control protocol
751
- configured_options = @options.dup_with(permission_prompt_tool_name: 'stdio')
752
- end
753
-
754
- # Advisory: warn if other options shadow the can_use_tool callback.
755
- # After the ArgumentError validations so invalid configs raise, not warn.
756
- OptionWarnings.warn_if_can_use_tool_shadowed(@options)
781
+ configured_options = ClaudeAgentSDK.configure_can_use_tool(@options)
757
782
 
758
783
  # Fail fast on invalid session_store combinations before spawning the CLI.
759
784
  # Configuration validation is a usage error, like the ArgumentErrors
@@ -1060,9 +1085,10 @@ module ClaudeAgentSDK
1060
1085
  # Convert hooks to internal format
1061
1086
  hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
1062
1087
 
1063
- # Extract exclude_dynamic_sections from preset system prompt for the
1064
- # initialize request (older CLIs ignore unknown initialize fields)
1088
+ # Extract exclude_dynamic_sections and snapshot from the system prompt
1089
+ # for the initialize request (older CLIs ignore unknown initialize fields)
1065
1090
  exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
1091
+ system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
1066
1092
 
1067
1093
  # Create Query handler
1068
1094
  @query_handler = Query.new(
@@ -1073,7 +1099,9 @@ module ClaudeAgentSDK
1073
1099
  sdk_mcp_servers: sdk_mcp_servers,
1074
1100
  agents: configured_options.agents,
1075
1101
  exclude_dynamic_sections: exclude_dynamic_sections,
1102
+ system_prompt_snapshot: system_prompt_snapshot,
1076
1103
  skills: configured_options.skills,
1104
+ forward_subagent_text: configured_options.forward_subagent_text?,
1077
1105
  callback_scheduling: @callback_scheduling,
1078
1106
  callback_wrapper: @callback_wrapper
1079
1107
  )
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-09 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -110,6 +110,7 @@ files:
110
110
  - CHANGELOG.md
111
111
  - LICENSE
112
112
  - README.md
113
+ - docs/cli-installer.md
113
114
  - docs/client.md
114
115
  - docs/configuration.md
115
116
  - docs/errors.md