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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +6 -2
- data/UPGRADING-1.0.md +151 -0
- data/docs/client.md +11 -0
- data/docs/configuration.md +42 -0
- data/docs/errors.md +6 -0
- data/docs/sessions.md +20 -1
- data/docs/types.md +17 -14
- data/lib/claude_agent_sdk/cli_installer.rb +1 -1
- data/lib/claude_agent_sdk/deprecation.rb +1 -40
- data/lib/claude_agent_sdk/errors.rb +10 -0
- data/lib/claude_agent_sdk/query.rb +320 -56
- data/lib/claude_agent_sdk/session_resume.rb +11 -5
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +25 -0
- data/lib/claude_agent_sdk/types/attributes.rb +14 -49
- data/lib/claude_agent_sdk/types/base.rb +2 -0
- data/lib/claude_agent_sdk/types/messages.rb +7 -1
- data/lib/claude_agent_sdk/types/options.rb +69 -12
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +28 -18
- data/sig/claude_agent_sdk/cancellation_signal.rbs +14 -0
- data/sig/claude_agent_sdk/configuration.rbs +14 -0
- data/sig/claude_agent_sdk/errors.rbs +86 -0
- data/sig/claude_agent_sdk/observer.rbs +42 -0
- data/sig/claude_agent_sdk/railtie.rbs +10 -0
- data/sig/claude_agent_sdk/sdk_mcp_server.rbs +76 -0
- data/sig/claude_agent_sdk/session_store.rbs +105 -0
- data/sig/claude_agent_sdk/streaming.rbs +15 -0
- data/sig/claude_agent_sdk/transport.rbs +98 -0
- data/sig/claude_agent_sdk/types/base.rbs +39 -0
- data/sig/claude_agent_sdk/types/content_blocks.rbs +79 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +528 -0
- data/sig/claude_agent_sdk/types/mcp.rbs +216 -0
- data/sig/claude_agent_sdk/types/messages.rbs +586 -0
- data/sig/claude_agent_sdk/types/option_values.rbs +245 -0
- data/sig/claude_agent_sdk/types/options.rbs +297 -0
- data/sig/claude_agent_sdk/types/permissions.rbs +108 -0
- data/sig/claude_agent_sdk/types/sessions.rbs +66 -0
- data/sig/claude_agent_sdk.rbs +231 -0
- data/sig/manifest.yaml +5 -0
- 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
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
#
|
|
1407
|
-
#
|
|
1408
|
-
#
|
|
1409
|
-
#
|
|
1410
|
-
#
|
|
1411
|
-
#
|
|
1412
|
-
#
|
|
1413
|
-
#
|
|
1414
|
-
#
|
|
1415
|
-
#
|
|
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):
|
|
1418
|
-
#
|
|
1419
|
-
#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
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
|
-
@
|
|
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
|
|
1430
|
-
#
|
|
1431
|
-
#
|
|
1432
|
-
#
|
|
1433
|
-
#
|
|
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
|
-
|
|
1440
|
-
|
|
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
|
-
#
|
|
1451
|
-
#
|
|
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
|
|
1454
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|