claude-agent-sdk 1.1.0 → 1.2.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +83 -108
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/option_forms.rb +379 -0
  22. data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
  23. data/lib/claude_agent_sdk/query.rb +266 -364
  24. data/lib/claude_agent_sdk/railtie.rb +27 -2
  25. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  26. data/lib/claude_agent_sdk/session_assembly.rb +311 -0
  27. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  28. data/lib/claude_agent_sdk/session_resume.rb +360 -43
  29. data/lib/claude_agent_sdk/session_store.rb +31 -2
  30. data/lib/claude_agent_sdk/sessions.rb +720 -138
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
  32. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  33. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  34. data/lib/claude_agent_sdk/transport.rb +28 -12
  35. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  36. data/lib/claude_agent_sdk/types/base.rb +85 -15
  37. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  38. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  39. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  40. data/lib/claude_agent_sdk/types/options.rb +35 -5
  41. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +223 -418
  44. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  45. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  46. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  47. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  48. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  49. metadata +9 -4
@@ -9,6 +9,7 @@ require 'timeout'
9
9
  require_relative 'transport'
10
10
  require_relative 'errors'
11
11
  require_relative 'cancellation_signal'
12
+ require_relative 'query/run_lifecycle'
12
13
 
13
14
  module ClaudeAgentSDK
14
15
  # Handles bidirectional control protocol on top of Transport
@@ -30,37 +31,22 @@ module ClaudeAgentSDK
30
31
  # @api private
31
32
  attr_reader :initialization_result
32
33
 
34
+ # When stdin may close (RunLifecycle). Read by specs.
35
+ #
36
+ # @api private
37
+ attr_reader :run_lifecycle
38
+
33
39
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
34
40
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
35
41
 
36
- # Task types whose completion runs a follow-up turn, and which therefore
37
- # may still need the control channel after the turn's result frame.
38
- #
39
- # Mirrors the set the CLI itself holds a result back for, which is
40
- # narrower than its notion of "delegated agent work". The types left out
41
- # are left out on purpose:
42
- # - background shells and monitors run indefinitely by design, so
43
- # deferring the close on one withholds it forever rather than briefly;
44
- # - teammates are long-lived too — their status stays running for their
45
- # whole lifetime, so they never settle the ledger;
46
- # - remote agents can be long-running monitors the CLI likewise refuses
47
- # to wait on.
48
- # Anything added here must be a type that reliably reaches a terminal
49
- # status, or it will hang the query (see #track_task_lifecycle).
50
- DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
51
-
52
42
  # The CLI's own wait for background work once stdin is closed; the SDK
53
43
  # bounds its wait for the CLI's "idle" by the same value
54
- # (#arm_run_end_ceiling, Python #1279).
44
+ # (RunLifecycle#arm, Python #1279).
55
45
  RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
56
46
  DEFAULT_RUN_END_CEILING_MS = 600_000
57
47
  # The longest ceiling honored (~24.8 days), as in the TypeScript and
58
48
  # Python SDKs, whose timers cannot run longer.
59
49
  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
50
 
65
51
  # Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
66
52
  # +options_env+ (ClaudeAgentOptions#env) overrides the inherited
@@ -76,33 +62,6 @@ module ClaudeAgentSDK
76
62
  digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
77
63
  end
78
64
 
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
65
  # Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
107
66
  # (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
108
67
  # On: returns a new Hash with `client_composed: true`, dropping any
@@ -136,14 +95,16 @@ module ClaudeAgentSDK
136
95
  end
137
96
  private_class_method :parse_streamed_message
138
97
 
139
- # Waiter for control responses awaited OFF the reactor — i.e. a control
140
- # method called from inside a hook/can_use_tool/SDK-MCP callback, which
141
- # runs on a FiberBoundary worker thread (Python supports this reentrancy
142
- # natively: callbacks are event-loop tasks and anyio.Event is
143
- # level-triggered). Duck-types Async::Condition#signal for the read
144
- # loop's signal sites; the unconditional token push makes it
145
- # level-triggered, closing the check-then-wait gap that an
146
- # edge-triggered Condition would lose across threads.
98
+ # Waiter for control responses awaited OFF the reactor that owns the
99
+ # Query — a control method called from inside a hook/can_use_tool/SDK-MCP
100
+ # callback, which runs on a FiberBoundary worker thread (Python supports
101
+ # this reentrancy natively: callbacks are event-loop tasks and
102
+ # anyio.Event is level-triggered), from any other plain thread, or from a
103
+ # fiber on another thread's reactor (see #send_control_request for the
104
+ # choice). Duck-types Async::Condition#signal for the read loop's signal
105
+ # sites; the unconditional token push makes it level-triggered, closing
106
+ # the check-then-wait gap that an edge-triggered Condition would lose
107
+ # across threads.
147
108
  class ThreadWaiter
148
109
  def initialize
149
110
  @queue = ::Queue.new
@@ -162,7 +123,7 @@ module ClaudeAgentSDK
162
123
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
163
124
  forward_subagent_text: false, agent_progress_summaries: nil,
164
125
  callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
165
- run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
126
+ run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS, sleeper: nil)
166
127
  @transport = transport
167
128
  @is_streaming_mode = is_streaming_mode
168
129
  @can_use_tool = can_use_tool
@@ -177,7 +138,6 @@ module ClaudeAgentSDK
177
138
  @forward_subagent_text = forward_subagent_text
178
139
  @agent_progress_summaries = agent_progress_summaries
179
140
  @verbatim_prompts = verbatim_prompts
180
- @run_end_ceiling_ms = run_end_ceiling_ms
181
141
 
182
142
  # Control protocol state
183
143
  @pending_control_responses = {}
@@ -193,36 +153,16 @@ module ClaudeAgentSDK
193
153
 
194
154
  # Message stream
195
155
  @message_queue = Async::Queue.new
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
220
- # Task IDs of started-but-not-finished deferring tasks. A result frame
221
- # only ends one turn, not the run: a background task keeps running past
222
- # it and still needs stdin for hook/SDK-MCP control responses (Python
223
- # #1088/#1103), so a result that arrives while this set is non-empty
224
- # must not close stdin.
225
- @inflight_tasks = Set.new
156
+ # Set once #receive_messages has consumed the read loop's end sentinel.
157
+ @stream_ended = false
158
+ # When stdin may close: the end of the run (Python #1088, #1190/#1279).
159
+ # +sleeper+ replaces the ceiling's timer in specs; the default reads @task
160
+ # when it is called, which is after #start.
161
+ @run_lifecycle = RunLifecycle.new(
162
+ ceiling_ms: run_end_ceiling_ms, sleeper: sleeper || method(:sleep_on_read_task),
163
+ bidirectional_needs: -> { bidirectional_needs? }, closed: -> { @closed },
164
+ control_requests_in_flight: -> { !@inflight_control_request_tasks.empty? }
165
+ )
226
166
  # Set to the result payload when the most recent message is a result
227
167
  # with is_error=true. Used to replace the generic "exit code 1"
228
168
  # ProcessError with a ResultError carrying what the CLI already
@@ -287,6 +227,10 @@ module ClaudeAgentSDK
287
227
  agents_dict = nil
288
228
  if @agents
289
229
  agents_dict = @agents.transform_values do |agent_def|
230
+ # A Hash stands for the AgentDefinition with the same attributes
231
+ # (the options signature allows either). Built through .new: the
232
+ # user wrote it, so a misspelled key raises as on the typed class.
233
+ agent_def = OptionForms.agent_definition(agent_def)
290
234
  {
291
235
  description: agent_def.description,
292
236
  prompt: agent_def.prompt,
@@ -365,18 +309,15 @@ module ClaudeAgentSDK
365
309
  # Reactor-side agent for #close calls arriving from foreign threads
366
310
  # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
367
311
  # the owning thread's Fiber.scheduler, so the off-thread caller hands the
368
- # whole close over and waits. Transient: must never keep the reactor
369
- # alive, and is stopped automatically when the parent task finishes.
370
- # One-shot: after serving a close it is done; a reactor-side close wakes
371
- # it via @close_requests.close (pop -> nil) so it exits without serving.
312
+ # whole close over and waits. Transient: while it waits for a request it
313
+ # must never keep the reactor alive, and it is stopped automatically
314
+ # once the reactor has nothing else to run. One-shot: it passes the
315
+ # first request on to a task of its own (#serve_marshalled_close) and
316
+ # is done; a reactor-side close wakes it via @close_requests.close
317
+ # (pop -> nil) so it exits without serving.
372
318
  @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
373
- if (reply = @close_requests.pop)
374
- begin
375
- close
376
- ensure
377
- reply << true
378
- end
379
- end
319
+ reply = @close_requests.pop
320
+ serve_marshalled_close(reply) if reply
380
321
  end)
381
322
  end
382
323
 
@@ -483,43 +424,27 @@ module ClaudeAgentSDK
483
424
  @transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
484
425
  next
485
426
  else
486
- # Track task lifecycle frames so results can tell "one turn ended"
487
- # apart from "the run is done" (Python #1088/#1103).
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
427
+ session_state_frame = msg_type == 'system' && message[:subtype] == 'session_state_changed'
428
+ # Flush the mirror before signaling/yielding the result so a
429
+ # consumer observing the result sees an up-to-date store for the turn.
430
+ flush_transcript_mirror if msg_type == 'result'
431
+ # Every frame that gets here drives the run's end (task lifecycle,
432
+ # session state, results, main-thread turns), whether or not it goes
433
+ # on to the stream.
434
+ @run_lifecycle.frame(message)
435
+ # Frames the CLI sent only because the transport asked for them
436
+ # (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt in,
437
+ # so they never reach the stream, observers or the parser.
438
+ next if session_state_frame && message[:sdk_host_only] == true
502
439
 
503
440
  if msg_type == 'result'
504
- # Flush the mirror before signaling/yielding the result so a
505
- # consumer observing the result sees an up-to-date store for the turn.
506
- flush_transcript_mirror
507
- on_result
508
441
  @last_error_result = message[:is_error] ? message : nil
509
- elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
442
+ elsif !session_state_frame
510
443
  # Anything other than the post-turn session_state_changed marker
511
444
  # means the conversation moved on; a ProcessError now is a fresh
512
445
  # crash, not the expected exit from a prior error result. Mirrors
513
446
  # the Python/TypeScript SDK reset logic.
514
447
  @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
523
448
  end
524
449
  # Regular SDK messages go to the queue
525
450
  @message_queue.enqueue(message)
@@ -571,182 +496,12 @@ module ClaudeAgentSDK
571
496
  # Unblock the stdin-closing waiter so it doesn't stall on early exit;
572
497
  # with the reader gone the run stays ended. Also stops a pending
573
498
  # ceiling sleeper, which would otherwise keep the reactor alive.
574
- @run_final = true
575
- end_run
499
+ @run_lifecycle.reader_gone
576
500
  # Always signal end of stream
577
501
  @message_queue.enqueue({ type: 'end' })
578
502
  end
579
503
  end
580
504
 
581
- # Track in-flight tasks from `system` task lifecycle frames.
582
- #
583
- # `task_started` marks a task in flight; `task_notification` or a
584
- # `task_updated` patch with a terminal status clears it. Terminal
585
- # completion can arrive as either frame (not every terminal task emits a
586
- # notification), so both are handled; Set deletion keeps the pair
587
- # idempotent.
588
- #
589
- # This is a mitigation, not a complete answer to Python #1088. An empty
590
- # set means "nothing we know of is running", which is not the same as
591
- # "the run is over": a task that settles *before* the turn's result frame
592
- # leaves the set empty at that result, so stdin closes even though the
593
- # completion may still wake the parent for a continuation turn. What this
594
- # does fix is the common ordering, where the task outlives the turn that
595
- # spawned it.
596
- #
597
- # Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
598
- # background *shell* is also reported through these frames, but it may
599
- # never reach a terminal status, and the CLI in stream-json mode only
600
- # exits on stdin EOF — tracking one would withhold the close forever.
601
- #
602
- # `background_tasks_changed` is deliberately not consumed, in either
603
- # direction: its payload is the live *background* set, while a subagent
604
- # is registered in the foreground and only flips to backgrounded later
605
- # without a second task_started, so narrowing against the snapshot would
606
- # drop an agent that goes on to outlive its turn, and widening from it
607
- # could admit an id no later frame ever clears (observer agents suppress
608
- # both their start and terminal frames).
609
- def track_task_lifecycle(message)
610
- task_id = message[:task_id]
611
- return if task_id.nil? || task_id.to_s.empty?
612
-
613
- case message[:subtype]
614
- when 'task_started'
615
- @inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
616
- when 'task_notification'
617
- @inflight_tasks.delete(task_id)
618
- when 'task_updated'
619
- patch = message[:patch]
620
- status = patch.is_a?(Hash) ? patch[:status] : nil
621
- @inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
622
- end
623
- end
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
-
750
505
  # Whether the CLI may still send control requests that need a reply.
751
506
  #
752
507
  # SDK MCP servers, hooks and the can_use_tool permission callback are all
@@ -765,6 +520,17 @@ module ClaudeAgentSDK
765
520
  !@sdk_mcp_servers.empty? || !@hooks.empty? || !!@can_use_tool
766
521
  end
767
522
 
523
+ # The run-end ceiling's timer (RunLifecycle's sleeper): runs +on_wake+ once
524
+ # +seconds+ have passed. A child of the read task, NOT a #spawn_task entry:
525
+ # it is re-armed at every result and every "running", and @child_tasks
526
+ # never prunes. The read task's stop cascades to it.
527
+ def sleep_on_read_task(seconds, &on_wake)
528
+ @task.async do
529
+ sleep seconds
530
+ on_wake.call
531
+ end
532
+ end
533
+
768
534
  # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
769
535
  # must never propagate into the read loop or its teardown.
770
536
  def flush_transcript_mirror
@@ -842,15 +608,42 @@ module ClaudeAgentSDK
842
608
  raise
843
609
  rescue StandardError => e
844
610
  send_control_error(request_id, e.message)
611
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
612
+ # What is left of the list: a callback (or its callback_wrapper)
613
+ # failing outside StandardError — NotImplementedError, LoadError,
614
+ # SystemStackError, SecurityError. No `rescue StandardError` on the
615
+ # way here caught it, not even the one in #handle_sdk_mcp_request that
616
+ # turns a resource or prompt handler's failure into its JSON-RPC
617
+ # error, so the answer an ordinary failure gets is built here.
618
+ # Unanswered, it would also end this task with an exception Async
619
+ # treats as fatal for the whole reactor.
620
+ respond_to_callback_failure(request_id, request_data, e.message)
621
+ end
622
+
623
+ # Answers the request, then leaves the process-exit exception to its
624
+ # caller to re-raise: the response an ordinary exception from the
625
+ # callback would have produced, naming the exception by class.
626
+ #
627
+ # The text has the format of FiberBoundary.process_exit_message, built
628
+ # here from the normalized message instead of calling it: that method
629
+ # joins the class name to the raw message, which raises
630
+ # Encoding::CompatibilityError for an encoding that is not
631
+ # ASCII-compatible (UTF-16). Raised in the rescue clause this runs in,
632
+ # that error would leave the request unanswered and replace the exit.
633
+ # +error+ is only read, never changed.
634
+ def respond_to_process_exit(request_id, request_data, error)
635
+ detail = wire_text(error.message)
636
+ message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
637
+ respond_to_callback_failure(request_id, request_data, message)
845
638
  end
846
639
 
847
640
  # The response an ordinary exception from the callback would have
848
- # produced, with the process-exit exception named by class: an error
849
- # control response for hooks / can_use_tool; for SDK MCP requests an
850
- # in-band isError result (tools/call) or a JSON-RPC internal error
851
- # (resources/read, prompts/get), inside a successful control response.
852
- def respond_to_process_exit(request_id, request_data, error)
853
- message = FiberBoundary.process_exit_message(error)
641
+ # produced, with +message+ as its text: an error control response for
642
+ # hooks / can_use_tool; for SDK MCP requests an in-band isError result
643
+ # (tools/call) or a JSON-RPC internal error (resources/read,
644
+ # prompts/get), inside a successful control response.
645
+ def respond_to_callback_failure(request_id, request_data, message)
646
+ message = wire_text(message)
854
647
  mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
855
648
  return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
856
649
 
@@ -867,10 +660,19 @@ module ClaudeAgentSDK
867
660
  response: { mcp_response: mcp_response }
868
661
  }
869
662
  }))
663
+ rescue JSON::GeneratorError
664
+ # Not reachable through the text, which #wire_text made encodable.
665
+ # Kept because an exception leaving this method would take the place
666
+ # of the process exit the caller is about to re-raise.
667
+ send_control_error(request_id, message)
870
668
  rescue CLIConnectionError
871
669
  nil # the CLI is already gone; nothing is waiting for the answer
872
670
  end
873
671
 
672
+ # Called from the rescue clauses of #handle_control_request, where an
673
+ # exception raised while building the answer has no rescue left: the
674
+ # request would stay unanswered. So the text goes through #wire_text, and
675
+ # whatever JSON.generate still rejects is replaced by a fixed one.
874
676
  def send_control_error(request_id, message)
875
677
  error_response = {
876
678
  type: 'control_response',
@@ -878,10 +680,16 @@ module ClaudeAgentSDK
878
680
  subtype: 'error',
879
681
  request_id: request_id,
880
682
  requestId: request_id,
881
- error: message
683
+ error: wire_text(message)
882
684
  }
883
685
  }
884
- writeln(JSON.generate(error_response))
686
+ line = begin
687
+ JSON.generate(error_response)
688
+ rescue JSON::GeneratorError
689
+ error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON'
690
+ JSON.generate(error_response)
691
+ end
692
+ writeln(line)
885
693
  rescue CLIConnectionError
886
694
  # EOF/close can invalidate a callback after the peer has gone away.
887
695
  # Only this best-effort reply is discarded; read errors still reach
@@ -889,6 +697,27 @@ module ClaudeAgentSDK
889
697
  nil
890
698
  end
891
699
 
700
+ # Error text as JSON.generate accepts it. An exception message can hold
701
+ # anything: a multibyte character cut by byteslice, the raw bytes of a
702
+ # subprocess or an HTTP body, a driver's own encoding. Valid UTF-8
703
+ # passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8,
704
+ # with U+FFFD in place of each byte that is not valid there. Any other
705
+ # encoding is transcoded, so valid text in it survives, with U+FFFD for
706
+ # what cannot be converted.
707
+ def wire_text(text)
708
+ text = text.to_s
709
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
710
+
711
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
712
+ text.dup.force_encoding(Encoding::UTF_8).scrub
713
+ else
714
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
715
+ end
716
+ rescue EncodingError
717
+ # No converter for the declared encoding (a dummy one such as UTF-7).
718
+ text.dup.force_encoding(Encoding::UTF_8).scrub
719
+ end
720
+
892
721
  def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
893
722
  raise 'canUseTool callback is not provided' unless @can_use_tool
894
723
 
@@ -1272,36 +1101,24 @@ module ClaudeAgentSDK
1272
1101
  { mcp_response: mcp_response }
1273
1102
  end
1274
1103
 
1275
- def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
1276
- # Handle typed output objects
1277
- return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
1278
-
1104
+ # What a hook callback returned, as the object the CLI reads. A typed
1105
+ # output (or anything else with #to_h) contributes its own Hash; a typed
1106
+ # value inside a Hash, such as a *HookSpecificOutput under
1107
+ # hook_specific_output, does the same. The keys of the result are then
1108
+ # normalized, so a Hash written in snake_case or with String keys means
1109
+ # what the typed output means (HookOutputKeys) — and so does the Hash a
1110
+ # typed output carried through as its hook_specific_output.
1111
+ def convert_hook_output_for_cli(hook_output)
1112
+ if hook_output.is_a?(Hash)
1113
+ hook_output = hook_output.transform_values do |value|
1114
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
1115
+ end
1116
+ elsif hook_output.respond_to?(:to_h)
1117
+ hook_output = hook_output.to_h
1118
+ end
1279
1119
  return {} unless hook_output.is_a?(Hash)
1280
1120
 
1281
- # Convert Ruby hash with symbol keys to CLI format
1282
- # Handle special keywords that might be Ruby-safe versions
1283
- converted = {}
1284
- hook_output.each do |key, value|
1285
- converted_key = case key
1286
- when :async_, 'async_' then 'async'
1287
- when :continue_, 'continue_' then 'continue'
1288
- when :hook_specific_output then 'hookSpecificOutput'
1289
- when :suppress_output then 'suppressOutput'
1290
- when :stop_reason then 'stopReason'
1291
- when :system_message then 'systemMessage'
1292
- when :async_timeout then 'asyncTimeout'
1293
- else key.to_s
1294
- end
1295
-
1296
- # Recursively convert nested objects
1297
- converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
1298
- value.to_h
1299
- else
1300
- value
1301
- end
1302
- converted[converted_key] = converted_value
1303
- end
1304
- converted
1121
+ HookOutputKeys.normalize(hook_output)
1305
1122
  end
1306
1123
 
1307
1124
  def send_control_request(request)
@@ -1316,10 +1133,25 @@ module ClaudeAgentSDK
1316
1133
  # RuntimeError; the eventual response dropped by the key? guard).
1317
1134
  task = Async::Task.current?
1318
1135
 
1319
- # Reactor callers wait on an Async::Condition; worker-thread callers
1320
- # on a ThreadWaiter. Register atomically with the terminal-state check
1321
- # so EOF cannot strand a sender that missed the final broadcast.
1322
- waiter = task ? Async::Condition.new : ThreadWaiter.new
1136
+ # The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only
1137
+ # a fiber of the reactor that owns this Query (the one #start ran on,
1138
+ # whose read loop does the signaling) checks its result slot and parks
1139
+ # with no chance for the read loop to run in between, so only there is
1140
+ # the edge-triggered Async::Condition safe. Every other caller races
1141
+ # the read loop between its check and its park and gets the
1142
+ # level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain
1143
+ # thread, and also a fiber on ANOTHER thread's reactor (`Sync {
1144
+ # client.interrupt }` on a request thread while the session lives on a
1145
+ # background reactor; a Sync block inside a :thread-mode callback).
1146
+ # Given a Condition, such a fiber could lose its wakeup on async >=
1147
+ # 2.29, and on async 2.10-2.28 the cross-thread signal raised
1148
+ # FiberError in the read loop, ending the whole session. The deadline
1149
+ # mechanism (#await_control_response) is still chosen by "has a task".
1150
+ #
1151
+ # Register atomically with the terminal-state check so EOF cannot
1152
+ # strand a sender that missed the final broadcast.
1153
+ on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler)
1154
+ waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new
1323
1155
  request_id = @request_counter_mutex.synchronize do
1324
1156
  raise @control_stream_error if @control_stream_error
1325
1157
 
@@ -1369,7 +1201,12 @@ module ClaudeAgentSDK
1369
1201
  # only this deadline is translated, not an outer task's cancellation.
1370
1202
  FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
1371
1203
  yield
1372
- waiter.wait until @pending_control_results.key?(request_id)
1204
+ # A ThreadWaiter here belongs to a fiber on a reactor that does not
1205
+ # own this Query: Thread::Queue#pop parks that fiber through its
1206
+ # own scheduler, and the deadline cancels it like any suspension.
1207
+ until @pending_control_results.key?(request_id)
1208
+ waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait
1209
+ end
1373
1210
  end
1374
1211
  else
1375
1212
  # Only schedulerless callers use stdlib Timeout. A fresh, private
@@ -1661,7 +1498,7 @@ module ClaudeAgentSDK
1661
1498
  # wait BETWEEN turns is bounded: if the CLI still reports "running"
1662
1499
  # CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
1663
1500
  # 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
1501
+ # (RunLifecycle#arm). A turn under way, a request the SDK is still
1665
1502
  # answering and a tracked background agent still in flight (Python #1088)
1666
1503
  # stop that clock. The run is guaranteed to end: as above, or in
1667
1504
  # read_messages' ensure when the process exits early.
@@ -1672,10 +1509,9 @@ module ClaudeAgentSDK
1672
1509
  # control requests from that later turn can find stdin closed.
1673
1510
  # Single-message and String prompts are fully covered.
1674
1511
  def wait_for_result_and_end_input
1675
- @run_end.wait if bidirectional_needs?
1512
+ @run_lifecycle.wait
1676
1513
  ensure
1677
- @run_final = true
1678
- clear_run_end_ceiling
1514
+ @run_lifecycle.stdin_closing
1679
1515
  @transport.end_input
1680
1516
  end
1681
1517
 
@@ -1698,9 +1534,7 @@ module ClaudeAgentSDK
1698
1534
  line = Query.serialize_user_message(message, @verbatim_prompts)
1699
1535
  # This message owes a run of its own, result included: an earlier
1700
1536
  # one having ended does not end it.
1701
- reopen_run
1702
- @result_received = false
1703
- clear_run_end_ceiling
1537
+ @run_lifecycle.message_will_write
1704
1538
  writeln(line)
1705
1539
  wrote_message = true
1706
1540
  end
@@ -1717,15 +1551,26 @@ module ClaudeAgentSDK
1717
1551
  # open until the run ends so hooks/SDK MCP control replies can still
1718
1552
  # be written (the run's end or process exit is guaranteed to signal).
1719
1553
  # - No complete message ever reached the CLI (empty stream, or the
1720
- # stream raised before the first write): no result can ever arrive,
1721
- # so waiting would park query() forever beside an idle CLI. Close
1722
- # stdin so the CLI sees EOF and exits. Deliberate improvement over
1723
- # Python, which leaves stdin open and hangs on this path.
1554
+ # stream raised before the first write): no result is owed, so there
1555
+ # is nothing to wait for. Close stdin at once so the CLI sees EOF and
1556
+ # exits, as the Python and TypeScript SDKs do.
1557
+ # This is a trade-off, not a free win. An empty stream is also how a
1558
+ # caller says "send nothing, just resume", and a resumed session can
1559
+ # have work of its own: a tool call that a PreToolUse hook deferred
1560
+ # is re-run by the CLI on resume. If that tool is served by an SDK
1561
+ # MCP server, the CLI's request for it finds stdin already closed and
1562
+ # the CLI exits with an error (ProcessError, exit code 1;
1563
+ # anthropics/claude-agent-sdk-python#1226). Waiting instead would
1564
+ # hang every empty resume that has nothing pending: a CLI that is
1565
+ # idle with no input reports no session state (seen with 2.1.286),
1566
+ # so the two cases cannot be told apart without a signal from the
1567
+ # CLI. Until there is one, resume a deferred tool through Client,
1568
+ # which keeps stdin open.
1724
1569
  unless @closed
1725
1570
  if wrote_message
1726
1571
  wait_for_result_and_end_input
1727
1572
  else
1728
- @run_final = true
1573
+ @run_lifecycle.empty_input
1729
1574
  @transport.end_input
1730
1575
  end
1731
1576
  end
@@ -1748,8 +1593,21 @@ module ClaudeAgentSDK
1748
1593
  # reception — ResultMessage dropped, the query reported as complete, and
1749
1594
  # on_error never fired. `while` propagates it like any other error.
1750
1595
  while true # rubocop:disable Style/InfiniteLoop
1596
+ # End of stream is sticky. The read loop enqueues ONE sentinel and is
1597
+ # gone, so once a receive has consumed it, every later one must end
1598
+ # at once rather than wait on a queue nothing writes to any more
1599
+ # (Python closes the send side of its stream, so later iterations
1600
+ # end at once there too). The end is remembered, not put back on the
1601
+ # queue: a receive can be made after the reactor has finished, and
1602
+ # async < 2.29 cannot enqueue outside a task. Anything still queued,
1603
+ # such as a mirror error reported after the end, is delivered first.
1604
+ break if @stream_ended && @message_queue.empty?
1605
+
1751
1606
  message = @message_queue.dequeue
1752
- break if message[:type] == 'end'
1607
+ if message[:type] == 'end'
1608
+ @stream_ended = true
1609
+ break
1610
+ end
1753
1611
  raise message[:error] if message[:type] == 'error'
1754
1612
 
1755
1613
  block.call(message)
@@ -1764,14 +1622,18 @@ module ClaudeAgentSDK
1764
1622
  # plain user threads — don't have; stopping from one raised NoMethodError
1765
1623
  # and left the read/child tasks running. Such callers hand the close to
1766
1624
  # the reactor-side watcher (spawned in #start) and wait for it to finish,
1767
- # so close semantics are identical regardless of the calling thread.
1625
+ # so close semantics are identical regardless of the calling thread. The
1626
+ # reactor waits for a close it was handed: it stays alive until the
1627
+ # transport's teardown is done, even when the session's own task has
1628
+ # nothing left to do.
1768
1629
  def close
1769
1630
  if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
1770
1631
  marshal_close_to_reactor
1771
1632
  else
1772
- # Same scheduler (reactor-side caller, including the watcher itself),
1773
- # or no live watcher: when the reactor is gone its task fibers are
1774
- # dead, so stopping them no longer touches Fiber.scheduler.
1633
+ # Same scheduler (reactor-side caller, including the task serving a
1634
+ # marshalled close), or no live reactor-side task to hand it to: when
1635
+ # the reactor is gone its task fibers are dead, so stopping them no
1636
+ # longer touches Fiber.scheduler.
1775
1637
  close_now
1776
1638
  end
1777
1639
  end
@@ -1852,8 +1714,8 @@ module ClaudeAgentSDK
1852
1714
  # surfaces as Async::Stop (Python parity: a hook that awaits
1853
1715
  # disconnect() gets CancelledError). Applied ONLY inside the trees of
1854
1716
  # the tasks being stopped: the reactor-side caller (Client#disconnect
1855
- # from the connect task, the close watcher) and foreign threads keep
1856
- # the plain path, unchanged.
1717
+ # from the connect task, the task serving a marshalled close) and
1718
+ # foreign threads keep the plain path, unchanged.
1857
1719
  #
1858
1720
  # The deferred Stop SUPERSEDES anything the teardown raises: async
1859
1721
  # raises it from defer_stop's ensure with an explicit `cause:`, so a
@@ -1929,11 +1791,51 @@ module ClaudeAgentSDK
1929
1791
  nil
1930
1792
  end
1931
1793
 
1932
- # Hand the close to the reactor and wait for completion. Polls watcher
1933
- # liveness instead of waiting forever: if the reactor shuts down
1934
- # concurrently (the transient watcher is stopped without serving the
1935
- # request), no reply will ever arrive — fall back to a direct close,
1936
- # which is safe once the reactor's fibers are dead.
1794
+ # Reactor side of a marshalled close; runs on the close watcher, which
1795
+ # must not run the close itself. The watcher is transient, and a reactor
1796
+ # does not wait for transient tasks: stopping the read task releases the
1797
+ # session's own task (the end sentinel), and if that was the last
1798
+ # non-transient task the reactor winds down and stops the watcher at the
1799
+ # first suspension point of the transport teardown. SubprocessCLITransport
1800
+ # then TERMs the CLI instead of closing stdin and granting the grace
1801
+ # period that lets it finish its last session write, and the caller is
1802
+ # released before the child is reaped.
1803
+ #
1804
+ # So the close runs in a task of its own that the reactor does wait for
1805
+ # (bounded by the transport's teardown). `defer_stop` on the watcher is
1806
+ # not enough: async 2.10 terminates the tasks of a finished reactor with
1807
+ # repeated stops, and a deferral survives only the first.
1808
+ #
1809
+ # The task is a SIBLING of the watcher, created under the watcher's
1810
+ # current parent. A reactor counts only its direct children, so a child
1811
+ # of the transient watcher would not hold it open — and that is what
1812
+ # `parent.async` creates once the task that started the session is gone
1813
+ # and async has re-parented the watcher to the reactor (Scheduler#async
1814
+ # adopts the current task as the parent).
1815
+ #
1816
+ # @close_watcher follows the close: the waiting caller polls it for
1817
+ # liveness (#marshal_close_to_reactor), so it has to name the task that
1818
+ # will answer. Switched before the new task can suspend, while the
1819
+ # watcher that spawned it is still alive.
1820
+ def serve_marshalled_close(reply)
1821
+ body = FiberBoundary.capture_otel_context do |task|
1822
+ @close_watcher = task
1823
+ begin
1824
+ close
1825
+ ensure
1826
+ reply << true
1827
+ end
1828
+ end
1829
+ Async::Task.new(Async::Task.current.parent, &body).run
1830
+ end
1831
+
1832
+ # Hand the close to the reactor and wait for completion. Polls the
1833
+ # liveness of the reactor-side task that is to answer (@close_watcher:
1834
+ # the idle watcher, then the task serving the close) instead of waiting
1835
+ # forever: if the reactor shuts down concurrently (the transient watcher
1836
+ # is stopped without serving the request), no reply will ever arrive —
1837
+ # fall back to a direct close, which is safe once the reactor's fibers
1838
+ # are dead.
1937
1839
  def marshal_close_to_reactor
1938
1840
  reply = ::Thread::Queue.new
1939
1841
  @close_requests << reply