claude-agent-sdk 0.37.0 → 1.1.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +6 -2
  4. data/UPGRADING-1.0.md +151 -0
  5. data/docs/client.md +11 -0
  6. data/docs/configuration.md +42 -0
  7. data/docs/errors.md +6 -0
  8. data/docs/sessions.md +20 -1
  9. data/docs/types.md +17 -14
  10. data/lib/claude_agent_sdk/cli_installer.rb +1 -1
  11. data/lib/claude_agent_sdk/deprecation.rb +1 -40
  12. data/lib/claude_agent_sdk/errors.rb +10 -0
  13. data/lib/claude_agent_sdk/query.rb +320 -56
  14. data/lib/claude_agent_sdk/session_resume.rb +11 -5
  15. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +25 -0
  16. data/lib/claude_agent_sdk/types/attributes.rb +14 -49
  17. data/lib/claude_agent_sdk/types/base.rb +2 -0
  18. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  19. data/lib/claude_agent_sdk/types/options.rb +69 -12
  20. data/lib/claude_agent_sdk/version.rb +1 -1
  21. data/lib/claude_agent_sdk.rb +28 -18
  22. data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
  23. data/sig/claude_agent_sdk/configuration.rbs +14 -0
  24. data/sig/claude_agent_sdk/errors.rbs +86 -0
  25. data/sig/claude_agent_sdk/observer.rbs +42 -0
  26. data/sig/claude_agent_sdk/railtie.rbs +10 -0
  27. data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
  28. data/sig/claude_agent_sdk/session_store.rbs +105 -0
  29. data/sig/claude_agent_sdk/streaming.rbs +15 -0
  30. data/sig/claude_agent_sdk/transport.rbs +98 -0
  31. data/sig/claude_agent_sdk/types/base.rbs +39 -0
  32. data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
  33. data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
  34. data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
  35. data/sig/claude_agent_sdk/types/messages.rbs +586 -0
  36. data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
  37. data/sig/claude_agent_sdk/types/options.rbs +297 -0
  38. data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
  39. data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
  40. data/sig/claude_agent_sdk.rbs +231 -0
  41. data/sig/manifest.yaml +5 -0
  42. metadata +23 -2
@@ -49,6 +49,93 @@ module ClaudeAgentSDK
49
49
  # status, or it will hang the query (see #track_task_lifecycle).
50
50
  DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
51
51
 
52
+ # The CLI's own wait for background work once stdin is closed; the SDK
53
+ # bounds its wait for the CLI's "idle" by the same value
54
+ # (#arm_run_end_ceiling, Python #1279).
55
+ RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
56
+ DEFAULT_RUN_END_CEILING_MS = 600_000
57
+ # The longest ceiling honored (~24.8 days), as in the TypeScript and
58
+ # Python SDKs, whose timers cannot run longer.
59
+ MAX_RUN_END_CEILING_MS = (2**31) - 1
60
+ # Frame types that mark a main-thread turn under way (when they carry no
61
+ # parent_tool_use_id), and the states that do not re-arm the ceiling.
62
+ TURN_FRAME_TYPES = %w[assistant stream_event].freeze
63
+ NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
64
+
65
+ # Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
66
+ # +options_env+ (ClaudeAgentOptions#env) overrides the inherited
67
+ # environment, as it does for the subprocess, and a key present with a nil
68
+ # value is unset in the child, so the CLI falls back to its default. 0
69
+ # means no limit. Only plain non-negative integers are read; anything else
70
+ # falls back to the CLI's default of 10 minutes, including spellings the
71
+ # CLI itself also reads, such as 1e6 (as in the Python SDK).
72
+ def self.run_end_ceiling_ms(options_env)
73
+ env = (options_env || {}).transform_keys(&:to_s)
74
+ raw = env.key?(RUN_END_CEILING_ENV_VAR) ? env[RUN_END_CEILING_ENV_VAR] : ENV.fetch(RUN_END_CEILING_ENV_VAR, nil)
75
+ digits = raw.to_s.strip
76
+ digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
77
+ end
78
+
79
+ # One run's end, set at most once (the Python SDK's per-run anyio.Event).
80
+ # A waiter holds the object it started waiting on, so a run that ends and
81
+ # is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
82
+ # waiters the ended run woke, while later waits wait for the new run.
83
+ # Reactor-only, like every caller.
84
+ class RunEnd
85
+ def initialize
86
+ @ended = false
87
+ @condition = Async::Condition.new
88
+ end
89
+
90
+ def ended?
91
+ @ended
92
+ end
93
+
94
+ def end!
95
+ return if @ended
96
+
97
+ @ended = true
98
+ @condition.signal
99
+ end
100
+
101
+ def wait
102
+ @condition.wait until @ended
103
+ end
104
+ end
105
+
106
+ # Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
107
+ # (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
108
+ # On: returns a new Hash with `client_composed: true`, dropping any
109
+ # caller-supplied value under either key spelling first (JSON.generate
110
+ # would otherwise emit the key twice). A pre-serialized JSONL String — a
111
+ # Ruby-only input shape — is parsed, marked and handed back as a Hash; one
112
+ # that is not a single JSON object raises ArgumentError, because sending it
113
+ # unmarked would silently defeat the option.
114
+ def self.stamp_user_message(message, verbatim_prompts)
115
+ return message unless verbatim_prompts
116
+
117
+ hash = message.is_a?(Hash) ? message : parse_streamed_message(message.to_s)
118
+ hash.reject { |key, _| key.to_s == 'client_composed' }.merge(client_composed: true)
119
+ end
120
+
121
+ # The serialized line for one outgoing user message, stamped per
122
+ # verbatim_prompts. Hashes are JSON-generated; other items pass through
123
+ # as their String form unless they must be stamped.
124
+ def self.serialize_user_message(message, verbatim_prompts)
125
+ stamped = stamp_user_message(message, verbatim_prompts)
126
+ stamped.is_a?(Hash) ? JSON.generate(stamped) : stamped.to_s
127
+ end
128
+
129
+ def self.parse_streamed_message(string)
130
+ parsed = JSON.parse(string)
131
+ return parsed if parsed.is_a?(Hash)
132
+
133
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (got #{parsed.class})"
134
+ rescue JSON::ParserError => e
135
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (#{e.message})"
136
+ end
137
+ private_class_method :parse_streamed_message
138
+
52
139
  # Waiter for control responses awaited OFF the reactor — i.e. a control
53
140
  # method called from inside a hook/can_use_tool/SDK-MCP callback, which
54
141
  # runs on a FiberBoundary worker thread (Python supports this reentrancy
@@ -74,7 +161,8 @@ module ClaudeAgentSDK
74
161
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
75
162
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
76
163
  forward_subagent_text: false, agent_progress_summaries: nil,
77
- callback_scheduling: :thread, callback_wrapper: nil)
164
+ callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
165
+ run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
78
166
  @transport = transport
79
167
  @is_streaming_mode = is_streaming_mode
80
168
  @can_use_tool = can_use_tool
@@ -88,6 +176,8 @@ module ClaudeAgentSDK
88
176
  @skills = skills
89
177
  @forward_subagent_text = forward_subagent_text
90
178
  @agent_progress_summaries = agent_progress_summaries
179
+ @verbatim_prompts = verbatim_prompts
180
+ @run_end_ceiling_ms = run_end_ceiling_ms
91
181
 
92
182
  # Control protocol state
93
183
  @pending_control_responses = {}
@@ -103,10 +193,30 @@ module ClaudeAgentSDK
103
193
 
104
194
  # Message stream
105
195
  @message_queue = Async::Queue.new
106
- # Set when a run-ending result arrives (a result frame with no tasks in
107
- # flight) so the stdin-closing waiter can wake. Named for history — it
108
- # once tracked the literal first result.
109
- @first_result_received = false
196
+ # Ends when the run is over, so the stdin-closing waiter can wake; see
197
+ # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
198
+ # Work the CLI takes up after the run ended swaps in a fresh one
199
+ # (#reopen_run).
200
+ @run_end = RunEnd.new
201
+ @result_received = false
202
+ # The CLI's latest session_state_changed state, or nil while it sends
203
+ # none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
204
+ # CLI that reports state stays "running" while a background agent is
205
+ # live or its completion is still to be handled, and reports "idle" once
206
+ # no further turn is owed.
207
+ @session_state = nil
208
+ # Ends the run if no new turn starts within the ceiling after a result
209
+ # (#arm_run_end_ceiling). The generation tells a sleeper that woke after
210
+ # it was cleared or re-armed to stand down.
211
+ @run_end_ceiling_task = nil
212
+ @run_end_ceiling_generation = 0
213
+ # A main-thread turn is under way (its assistant/stream_event frames
214
+ # have started and its result has not arrived): the ceiling counts only
215
+ # the wait between turns, so it is not armed meanwhile.
216
+ @turn_in_progress = false
217
+ # Set once stdin is closed or the reader is gone: the run then stays
218
+ # ended, since nothing can wait on a reopened one.
219
+ @run_final = false
110
220
  # Task IDs of started-but-not-finished deferring tasks. A result frame
111
221
  # only ends one turn, not the run: a background task keeps running past
112
222
  # it and still needs stdin for hook/SDK-MCP control responses (Python
@@ -119,7 +229,6 @@ module ClaudeAgentSDK
119
229
  # reported. Mirrors the TypeScript SDK's `lastErrorResultText`
120
230
  # (Query.ts), but keeps the whole payload rather than just the text.
121
231
  @last_error_result = nil
122
- @first_result_condition = Async::Condition.new
123
232
  @task = nil
124
233
  @child_tasks = []
125
234
  @initialized = false
@@ -376,22 +485,26 @@ module ClaudeAgentSDK
376
485
  else
377
486
  # Track task lifecycle frames so results can tell "one turn ended"
378
487
  # apart from "the run is done" (Python #1088/#1103).
379
- track_task_lifecycle(message) if message[:type] == 'system'
488
+ if msg_type == 'system'
489
+ had_tasks_in_flight = !@inflight_tasks.empty?
490
+ track_task_lifecycle(message)
491
+ # The ceiling left the last tracked agent alone; the wait between
492
+ # turns starts over now that it settled.
493
+ rearm_run_end_ceiling_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
494
+ if message[:subtype] == 'session_state_changed'
495
+ on_session_state(message[:state])
496
+ # Frames the CLI sent only because the transport asked for them
497
+ # (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt
498
+ # in, so they never reach the stream, observers or the parser.
499
+ next if message[:sdk_host_only] == true
500
+ end
501
+ end
380
502
 
381
- if message[:type] == 'result'
503
+ if msg_type == 'result'
382
504
  # Flush the mirror before signaling/yielding the result so a
383
505
  # consumer observing the result sees an up-to-date store for the turn.
384
506
  flush_transcript_mirror
385
- # A result with tasks still in flight ends one turn, not the run:
386
- # the tasks may still need hook/SDK-MCP control responses over
387
- # stdin, and closing it now silently disables hooks and fails
388
- # SDK-MCP calls with "Stream closed". Each deferring task's
389
- # completion wakes the parent for a follow-up turn, so a later
390
- # result arrives with no tasks in flight and closes stdin then.
391
- if @inflight_tasks.empty? && !@first_result_received
392
- @first_result_received = true
393
- @first_result_condition.signal
394
- end
507
+ on_result
395
508
  @last_error_result = message[:is_error] ? message : nil
396
509
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
397
510
  # Anything other than the post-turn session_state_changed marker
@@ -399,6 +512,14 @@ module ClaudeAgentSDK
399
512
  # crash, not the expected exit from a prior error result. Mirrors
400
513
  # the Python/TypeScript SDK reset logic.
401
514
  @last_error_result = nil
515
+ # A main-thread turn is under way, so the ceiling stops (it counts
516
+ # only the wait between turns, as the CLI's does) and the run
517
+ # reopens even if the ceiling ended it while no state changed.
518
+ if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
519
+ @turn_in_progress = true
520
+ reopen_run
521
+ clear_run_end_ceiling
522
+ end
402
523
  end
403
524
  # Regular SDK messages go to the queue
404
525
  @message_queue.enqueue(message)
@@ -447,10 +568,11 @@ module ClaudeAgentSDK
447
568
  begin
448
569
  flush_transcript_mirror
449
570
  ensure
450
- unless @first_result_received
451
- @first_result_received = true
452
- @first_result_condition.signal
453
- end
571
+ # Unblock the stdin-closing waiter so it doesn't stall on early exit;
572
+ # with the reader gone the run stays ended. Also stops a pending
573
+ # ceiling sleeper, which would otherwise keep the reactor alive.
574
+ @run_final = true
575
+ end_run
454
576
  # Always signal end of stream
455
577
  @message_queue.enqueue({ type: 'end' })
456
578
  end
@@ -500,6 +622,131 @@ module ClaudeAgentSDK
500
622
  end
501
623
  end
502
624
 
625
+ # A result ends a turn, not necessarily the run: a background agent that
626
+ # finished just before it still wakes the session for another turn, whose
627
+ # hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
628
+ # CLI that reports session state stays "running" while such a turn is
629
+ # owed, so wait for "idle" (some hosts send it just before the result).
630
+ # Without state events the result is all there is to go on.
631
+ def on_result
632
+ @result_received = true
633
+ @turn_in_progress = false
634
+ if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
635
+ maybe_end_run
636
+ elsif @session_state != 'requires_action'
637
+ # While the SDK is still answering a request the ceiling waits for
638
+ # the "running" that follows.
639
+ arm_run_end_ceiling
640
+ end
641
+ end
642
+
643
+ # Track the CLI's session_state_changed state (Python #1279).
644
+ def on_session_state(state)
645
+ @session_state = state
646
+ if state == 'idle'
647
+ maybe_end_run if @result_received
648
+ return
649
+ end
650
+ # Work the CLI took up after the run ended (a finished background task
651
+ # woke it) reopens the run until the next "idle".
652
+ reopen_run
653
+ if state == 'requires_action'
654
+ # The host is answering a request; stdin must outlast it.
655
+ clear_run_end_ceiling
656
+ else
657
+ rearm_run_end_ceiling_between_turns
658
+ end
659
+ end
660
+
661
+ # End the run unless a tracked background task is still in flight: such
662
+ # a task may still need hook/SDK-MCP control responses over stdin
663
+ # (Python #1088), and its completion wakes the parent for a follow-up
664
+ # turn whose result (or "idle") ends the run then. A CLI that reports
665
+ # session state never reports "idle" with an agent still live, so this
666
+ # matters for CLIs that report "idle" at every turn end or not at all.
667
+ def maybe_end_run
668
+ end_run if @inflight_tasks.empty?
669
+ end
670
+
671
+ # The run is over: wake the stdin-closing waiter. Idempotent.
672
+ def end_run
673
+ clear_run_end_ceiling
674
+ @run_end.end!
675
+ end
676
+
677
+ # Reopen an ended run for work that started after it ended. A waiter the
678
+ # ended run already woke still closes stdin; this makes a later wait
679
+ # (stream_input's, once its prompts are all written) wait for the new
680
+ # work too. Once stdin is closed, or the reader is gone, the run stays
681
+ # ended.
682
+ def reopen_run
683
+ @run_end = RunEnd.new if @run_end.ended? && !@run_final
684
+ end
685
+
686
+ # End the run anyway once the ceiling passes with no new turn. The CLI's
687
+ # own background-wait ceiling only counts once stdin is closed, so without
688
+ # this, work that never finishes would hold "running", and stdin, open
689
+ # forever. It counts only the wait between turns: restarted at each result
690
+ # and whenever the CLI reports "running" again, cleared by main-thread
691
+ # turn activity and by "requires_action", never armed mid-turn.
692
+ #
693
+ # The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
694
+ # result and every "running", and @child_tasks never prunes. The read
695
+ # task's stop cascades to it, and every exit path clears it (#end_run in
696
+ # the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
697
+ # pending sleeper can never keep the enclosing reactor alive.
698
+ def arm_run_end_ceiling
699
+ clear_run_end_ceiling
700
+ # A frame read while close is under way (the result branch flushes the
701
+ # mirror first) must not leave a sleeper behind either.
702
+ return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
703
+ @turn_in_progress || !bidirectional_needs?
704
+
705
+ generation = @run_end_ceiling_generation
706
+ seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
707
+ @run_end_ceiling_task = @task.async do
708
+ sleep seconds
709
+ end_run_at_ceiling(generation)
710
+ end
711
+ end
712
+
713
+ # Restart the ceiling if the run is between turns, past a result, with
714
+ # the CLI still reporting work ("running").
715
+ def rearm_run_end_ceiling_between_turns
716
+ return unless @result_received
717
+ return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
718
+
719
+ arm_run_end_ceiling
720
+ end
721
+
722
+ def end_run_at_ceiling(generation)
723
+ # Cleared or re-armed while this sleeper was already waking up.
724
+ return unless generation == @run_end_ceiling_generation
725
+
726
+ # Detach before ending the run: #end_run clears the ceiling, and
727
+ # clearing it must not stop the task running this very method.
728
+ @run_end_ceiling_task = nil
729
+ # A tracked background agent still running may still need stdin for its
730
+ # hook, permission and SDK MCP requests (Python #1088), so it is not cut
731
+ # off; the ceiling starts over once it settles (#read_messages).
732
+ return unless @inflight_tasks.empty?
733
+
734
+ # A control request the SDK is still answering (a slow hook or SDK MCP
735
+ # tool) must be able to write its reply, so the clock starts over rather
736
+ # than closing stdin under it. Ruby-only guard: Python relies on the CLI
737
+ # reporting requires_action for every such request.
738
+ return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
739
+
740
+ end_run
741
+ end
742
+
743
+ def clear_run_end_ceiling
744
+ @run_end_ceiling_generation += 1
745
+ task = @run_end_ceiling_task
746
+ @run_end_ceiling_task = nil
747
+ task&.stop
748
+ end
749
+
503
750
  # Whether the CLI may still send control requests that need a reply.
504
751
  #
505
752
  # SDK MCP servers, hooks and the can_use_tool permission callback are all
@@ -1399,45 +1646,62 @@ module ClaudeAgentSDK
1399
1646
  })
1400
1647
  end
1401
1648
 
1402
- # Wait for a run-ending result before closing stdin when hooks, SDK MCP
1403
- # servers or a can_use_tool callback may still need to exchange control
1404
- # messages with the CLI.
1405
- # The control protocol requires stdin to stay open for the entire turn
1406
- # (hook replies, can_use_tool replies and SDK MCP tool results are all
1407
- # written to stdin), so no timeout is applied — closing stdin mid-turn
1408
- # silently broke hooks/MCP on turns longer than the old 60s bound
1409
- # (mirrors Python SDK commit c3d96cb). A result frame ends one turn, not
1410
- # necessarily the run: while background tasks are in flight the result
1411
- # branch withholds the signal (Python #1088/#1103), and each deferring
1412
- # task's completion wakes the parent for a follow-up turn that ends in
1413
- # another result. The condition is guaranteed to be signaled: by the
1414
- # result branch in read_messages once no tasks are in flight, or by its
1415
- # ensure block when the process exits early.
1649
+ # Wait for the end of the run, when hooks, SDK MCP servers or a
1650
+ # can_use_tool callback may still need to exchange control messages with
1651
+ # the CLI, then close stdin. Their replies are all written to stdin, so it
1652
+ # must stay open until the run ends: at the CLI's "idle" session state
1653
+ # after a result, or, from a CLI that reports no session state, at the
1654
+ # first result with no tracked tasks in flight. A result frame ends one
1655
+ # turn, not necessarily the run: background tasks keep running past it,
1656
+ # or have just finished and still wake the parent for a follow-up turn,
1657
+ # and those turns need stdin for control responses (Python #1088, #1190).
1658
+ #
1659
+ # No timeout bounds a turn (closing stdin mid-turn silently broke
1660
+ # hooks/MCP on turns longer than the old 60s bound; Python c3d96cb). The
1661
+ # wait BETWEEN turns is bounded: if the CLI still reports "running"
1662
+ # CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
1663
+ # limit) after a result with no new turn, the run ends anyway
1664
+ # (#arm_run_end_ceiling). A turn under way, a request the SDK is still
1665
+ # answering and a tracked background agent still in flight (Python #1088)
1666
+ # stop that clock. The run is guaranteed to end: as above, or in
1667
+ # read_messages' ensure when the process exits early.
1416
1668
  #
1417
- # Known limitation (same as Python's): the condition is one-shot and is
1418
- # not aware of prompt messages still queued CLI-side, so an Enumerator
1419
- # prompt yielding several user messages (several turns) releases the hold
1420
- # at the first turn boundary with no tracked tasks; control requests from
1421
- # later turns can then find stdin closed. Single-message and String
1422
- # prompts — the common one-shot shapes — are fully covered.
1669
+ # Known limitation (same as Python's): from a CLI that reports no session
1670
+ # state, a result for an earlier message of an Enumerator prompt still
1671
+ # ends the run even when a later message is already queued CLI-side, so
1672
+ # control requests from that later turn can find stdin closed.
1673
+ # Single-message and String prompts are fully covered.
1423
1674
  def wait_for_result_and_end_input
1424
- @first_result_condition.wait if !@first_result_received && bidirectional_needs?
1675
+ @run_end.wait if bidirectional_needs?
1425
1676
  ensure
1677
+ @run_final = true
1678
+ clear_run_end_ceiling
1426
1679
  @transport.end_input
1427
1680
  end
1428
1681
 
1429
- # Stream input messages to transport. NOTE: iteration runs on the
1430
- # reactor (the deliberate FiberBoundary carve-out — see
1431
- # fiber_boundary.rb): scheduler-aware blocking (Thread::Queue#pop,
1432
- # sleep, socket IO) parks only this task; CPU-bound or scheduler-opaque
1433
- # work in the enumerator must be moved to a producer Thread by the user.
1682
+ # Stream input messages to transport, then close stdin once the run ends
1683
+ # (#wait_for_result_and_end_input). Each message written owes a run of its
1684
+ # own, so the wait is for the last message's run, not an earlier one's.
1685
+ #
1686
+ # NOTE: iteration runs on the reactor (the deliberate FiberBoundary
1687
+ # carve-out — see fiber_boundary.rb): scheduler-aware blocking
1688
+ # (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or
1689
+ # scheduler-opaque work in the enumerator must be moved to a producer
1690
+ # Thread by the user.
1434
1691
  def stream_input(stream)
1435
1692
  wrote_message = false
1436
1693
  stream.each do |message|
1437
1694
  break if @closed
1438
1695
 
1439
- serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
1440
- writeln(serialized)
1696
+ # Serialized first: a message verbatim_prompts cannot mark raises
1697
+ # here, before the run is reopened for a message that never went out.
1698
+ line = Query.serialize_user_message(message, @verbatim_prompts)
1699
+ # This message owes a run of its own, result included: an earlier
1700
+ # one having ended does not end it.
1701
+ reopen_run
1702
+ @result_received = false
1703
+ clear_run_end_ceiling
1704
+ writeln(line)
1441
1705
  wrote_message = true
1442
1706
  end
1443
1707
  rescue StandardError => e
@@ -1446,13 +1710,12 @@ module ClaudeAgentSDK
1446
1710
  ensure
1447
1711
  # Three teardown shapes:
1448
1712
  # - #close in progress (@closed, Async::Stop unwinding): do nothing —
1449
- # the transport is about to be closed, and waiting on
1450
- # @first_result_condition inside a stopping fiber could suspend
1451
- # teardown. Mirrors Python, where cancellation skips this entirely.
1713
+ # the transport is about to be closed, and waiting on the run's end
1714
+ # inside a stopping fiber could suspend teardown. Mirrors Python,
1715
+ # where cancellation skips this entirely.
1452
1716
  # - A turn is in flight (some message reached the CLI): hold stdin
1453
- # open until its first result so hooks/SDK MCP control replies can
1454
- # still be written (no timeout — the result or process exit is
1455
- # guaranteed to signal).
1717
+ # open until the run ends so hooks/SDK MCP control replies can still
1718
+ # be written (the run's end or process exit is guaranteed to signal).
1456
1719
  # - No complete message ever reached the CLI (empty stream, or the
1457
1720
  # stream raised before the first write): no result can ever arrive,
1458
1721
  # so waiting would park query() forever beside an idle CLI. Close
@@ -1462,6 +1725,7 @@ module ClaudeAgentSDK
1462
1725
  if wrote_message
1463
1726
  wait_for_result_and_end_input
1464
1727
  else
1728
+ @run_final = true
1465
1729
  @transport.end_input
1466
1730
  end
1467
1731
  end
@@ -119,7 +119,8 @@ module ClaudeAgentSDK
119
119
  # Returns a MaterializedResume, or nil when no materialization is needed
120
120
  # (no store, no resume/continue, store has no entries, or the resolved
121
121
  # session id is not a valid UUID) — the caller then falls through to the
122
- # normal spawn path. Raises RuntimeError if a store call fails or times out.
122
+ # normal spawn path. Raises SessionStoreError (#cause: the adapter's own
123
+ # exception or the timeout) if a store call fails or times out.
123
124
  def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order
124
125
  store = options.session_store
125
126
  return nil if store.nil?
@@ -290,7 +291,12 @@ module ClaudeAgentSDK
290
291
  end
291
292
 
292
293
  # Run a store call (user code) on a plain thread bounded by timeout_s,
293
- # re-raising failures/timeouts as RuntimeError with context. The thread hop
294
+ # re-raising failures/timeouts as SessionStoreError with context, the
295
+ # original as #cause (Ruby sets it: the raise is inside the rescue). Every
296
+ # StandardError is wrapped, the adapter's own RuntimeError included, so
297
+ # `rescue ClaudeSDKError` catches every materialization failure (0.x let
298
+ # a RuntimeError through unwrapped and raised the rest as bare
299
+ # RuntimeErrors, mirroring Python). The thread hop
294
300
  # (the default for FiberBoundary with a timeout) both keeps the async
295
301
  # scheduler out of the user's store code AND enforces load_timeout_ms
296
302
  # unconditionally — including when materialization runs outside an Async
@@ -305,11 +311,11 @@ module ClaudeAgentSDK
305
311
  def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &)
306
312
  FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &)
307
313
  rescue FiberBoundary::JoinTimeout
308
- raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
309
- rescue RuntimeError
314
+ raise SessionStoreError, "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
315
+ rescue SessionStoreError
310
316
  raise
311
317
  rescue StandardError => e
312
- raise "#{what} failed during resume materialization: #{e}"
318
+ raise SessionStoreError, "#{what} failed during resume materialization: #{e.class}: #{e.message}"
313
319
  end
314
320
 
315
321
  # Write pre-encoded JSON lines (see encode_jsonl_lines), one per line,
@@ -16,6 +16,15 @@ module ClaudeAgentSDK
16
16
  DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
17
17
  # @api private
18
18
  MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
19
+ # First Claude Code version that honors `client_composed` on user
20
+ # messages, which ClaudeAgentOptions#verbatim_prompts relies on.
21
+ # @api private
22
+ VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION = '2.1.248'
23
+ # Asks the CLI for session_state_changed frames marked sdk_host_only,
24
+ # which Query reads to tell when the run is over and keeps out of the
25
+ # caller's stream. CLIs that predate it send no frames.
26
+ # @api private
27
+ SDK_READS_SESSION_STATE_ENV_VAR = 'CLAUDE_CODE_SDK_READS_SESSION_STATE'
19
28
  # @api private
20
29
  SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
21
30
  # @api private
@@ -288,6 +297,15 @@ module ClaudeAgentSDK
288
297
  # under the caller's distributed trace (Python SDK #821 parity). No-op
289
298
  # when opentelemetry is not loaded or there is no active span.
290
299
  inject_otel_trace_context(process_env, custom_env)
300
+ # Query waits for the CLI's session_state_changed "idle" before closing
301
+ # stdin on a run that serves control requests (Python #1279). Ask for
302
+ # the frames it drops (sdk_host_only) unless the caller named the
303
+ # variable, in any case, in options.env (a nil value unsets it) or the
304
+ # inherited environment. CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS stays the
305
+ # caller's own opt-in to seeing the frames; the SDK never sets it.
306
+ unless process_env.keys.any? { |key| key.casecmp?(SDK_READS_SESSION_STATE_ENV_VAR) }
307
+ process_env[SDK_READS_SESSION_STATE_ENV_VAR] = '1'
308
+ end
291
309
  process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
292
310
  process_env['PWD'] = @cwd.to_s if @cwd
293
311
 
@@ -922,6 +940,13 @@ module ClaudeAgentSDK
922
940
  'Some features may not work correctly.'
923
941
  warn warning
924
942
  end
943
+
944
+ verbatim_min_parts = VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
945
+ if @options.verbatim_prompts? && (version_parts <=> verbatim_min_parts).negative?
946
+ warn "Warning: verbatim_prompts is enabled, but Claude Code version #{version} at #{@cli_path} " \
947
+ 'ignores it: prompts will still have @path mentions expanded and slash commands dispatched. ' \
948
+ "Claude Code #{VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION} or later is required."
949
+ end
925
950
  end
926
951
  rescue StandardError
927
952
  # Ignore version check errors — including Timeout::Error from the