claude-agent-sdk 1.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/claude_agent_sdk/command_builder.rb +48 -130
- data/lib/claude_agent_sdk/option_forms.rb +379 -0
- data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
- data/lib/claude_agent_sdk/query.rb +47 -296
- data/lib/claude_agent_sdk/session_assembly.rb +311 -0
- data/lib/claude_agent_sdk/session_resume.rb +4 -4
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +10 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +163 -406
- metadata +5 -2
|
@@ -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
|
-
# (#
|
|
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
|
|
@@ -164,7 +123,7 @@ module ClaudeAgentSDK
|
|
|
164
123
|
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
165
124
|
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
166
125
|
callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
|
|
167
|
-
run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
|
|
126
|
+
run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS, sleeper: nil)
|
|
168
127
|
@transport = transport
|
|
169
128
|
@is_streaming_mode = is_streaming_mode
|
|
170
129
|
@can_use_tool = can_use_tool
|
|
@@ -179,7 +138,6 @@ module ClaudeAgentSDK
|
|
|
179
138
|
@forward_subagent_text = forward_subagent_text
|
|
180
139
|
@agent_progress_summaries = agent_progress_summaries
|
|
181
140
|
@verbatim_prompts = verbatim_prompts
|
|
182
|
-
@run_end_ceiling_ms = run_end_ceiling_ms
|
|
183
141
|
|
|
184
142
|
# Control protocol state
|
|
185
143
|
@pending_control_responses = {}
|
|
@@ -197,36 +155,14 @@ module ClaudeAgentSDK
|
|
|
197
155
|
@message_queue = Async::Queue.new
|
|
198
156
|
# Set once #receive_messages has consumed the read loop's end sentinel.
|
|
199
157
|
@stream_ended = false
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
# CLI that reports state stays "running" while a background agent is
|
|
209
|
-
# live or its completion is still to be handled, and reports "idle" once
|
|
210
|
-
# no further turn is owed.
|
|
211
|
-
@session_state = nil
|
|
212
|
-
# Ends the run if no new turn starts within the ceiling after a result
|
|
213
|
-
# (#arm_run_end_ceiling). The generation tells a sleeper that woke after
|
|
214
|
-
# it was cleared or re-armed to stand down.
|
|
215
|
-
@run_end_ceiling_task = nil
|
|
216
|
-
@run_end_ceiling_generation = 0
|
|
217
|
-
# A main-thread turn is under way (its assistant/stream_event frames
|
|
218
|
-
# have started and its result has not arrived): the ceiling counts only
|
|
219
|
-
# the wait between turns, so it is not armed meanwhile.
|
|
220
|
-
@turn_in_progress = false
|
|
221
|
-
# Set once stdin is closed or the reader is gone: the run then stays
|
|
222
|
-
# ended, since nothing can wait on a reopened one.
|
|
223
|
-
@run_final = false
|
|
224
|
-
# Task IDs of started-but-not-finished deferring tasks. A result frame
|
|
225
|
-
# only ends one turn, not the run: a background task keeps running past
|
|
226
|
-
# it and still needs stdin for hook/SDK-MCP control responses (Python
|
|
227
|
-
# #1088/#1103), so a result that arrives while this set is non-empty
|
|
228
|
-
# must not close stdin.
|
|
229
|
-
@inflight_tasks = Set.new
|
|
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
|
+
)
|
|
230
166
|
# Set to the result payload when the most recent message is a result
|
|
231
167
|
# with is_error=true. Used to replace the generic "exit code 1"
|
|
232
168
|
# ProcessError with a ResultError carrying what the CLI already
|
|
@@ -294,7 +230,7 @@ module ClaudeAgentSDK
|
|
|
294
230
|
# A Hash stands for the AgentDefinition with the same attributes
|
|
295
231
|
# (the options signature allows either). Built through .new: the
|
|
296
232
|
# user wrote it, so a misspelled key raises as on the typed class.
|
|
297
|
-
agent_def =
|
|
233
|
+
agent_def = OptionForms.agent_definition(agent_def)
|
|
298
234
|
{
|
|
299
235
|
description: agent_def.description,
|
|
300
236
|
prompt: agent_def.prompt,
|
|
@@ -488,43 +424,27 @@ module ClaudeAgentSDK
|
|
|
488
424
|
@transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
|
|
489
425
|
next
|
|
490
426
|
else
|
|
491
|
-
|
|
492
|
-
#
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
# in, so they never reach the stream, observers or the parser.
|
|
504
|
-
next if message[:sdk_host_only] == true
|
|
505
|
-
end
|
|
506
|
-
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
|
|
507
439
|
|
|
508
440
|
if msg_type == 'result'
|
|
509
|
-
# Flush the mirror before signaling/yielding the result so a
|
|
510
|
-
# consumer observing the result sees an up-to-date store for the turn.
|
|
511
|
-
flush_transcript_mirror
|
|
512
|
-
on_result
|
|
513
441
|
@last_error_result = message[:is_error] ? message : nil
|
|
514
|
-
elsif !
|
|
442
|
+
elsif !session_state_frame
|
|
515
443
|
# Anything other than the post-turn session_state_changed marker
|
|
516
444
|
# means the conversation moved on; a ProcessError now is a fresh
|
|
517
445
|
# crash, not the expected exit from a prior error result. Mirrors
|
|
518
446
|
# the Python/TypeScript SDK reset logic.
|
|
519
447
|
@last_error_result = nil
|
|
520
|
-
# A main-thread turn is under way, so the ceiling stops (it counts
|
|
521
|
-
# only the wait between turns, as the CLI's does) and the run
|
|
522
|
-
# reopens even if the ceiling ended it while no state changed.
|
|
523
|
-
if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
|
|
524
|
-
@turn_in_progress = true
|
|
525
|
-
reopen_run
|
|
526
|
-
clear_run_end_ceiling
|
|
527
|
-
end
|
|
528
448
|
end
|
|
529
449
|
# Regular SDK messages go to the queue
|
|
530
450
|
@message_queue.enqueue(message)
|
|
@@ -576,189 +496,12 @@ module ClaudeAgentSDK
|
|
|
576
496
|
# Unblock the stdin-closing waiter so it doesn't stall on early exit;
|
|
577
497
|
# with the reader gone the run stays ended. Also stops a pending
|
|
578
498
|
# ceiling sleeper, which would otherwise keep the reactor alive.
|
|
579
|
-
@
|
|
580
|
-
end_run
|
|
499
|
+
@run_lifecycle.reader_gone
|
|
581
500
|
# Always signal end of stream
|
|
582
501
|
@message_queue.enqueue({ type: 'end' })
|
|
583
502
|
end
|
|
584
503
|
end
|
|
585
504
|
|
|
586
|
-
# Track in-flight tasks from `system` task lifecycle frames.
|
|
587
|
-
#
|
|
588
|
-
# `task_started` marks a task in flight; `task_notification` or a
|
|
589
|
-
# `task_updated` patch with a terminal status clears it. Terminal
|
|
590
|
-
# completion can arrive as either frame (not every terminal task emits a
|
|
591
|
-
# notification), so both are handled; Set deletion keeps the pair
|
|
592
|
-
# idempotent.
|
|
593
|
-
#
|
|
594
|
-
# This is a mitigation, not a complete answer to Python #1088. An empty
|
|
595
|
-
# set means "nothing we know of is running", which is not the same as
|
|
596
|
-
# "the run is over": a task that settles *before* the turn's result frame
|
|
597
|
-
# leaves the set empty at that result, so stdin closes even though the
|
|
598
|
-
# completion may still wake the parent for a continuation turn. What this
|
|
599
|
-
# does fix is the common ordering, where the task outlives the turn that
|
|
600
|
-
# spawned it.
|
|
601
|
-
#
|
|
602
|
-
# Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
|
|
603
|
-
# background *shell* is also reported through these frames, but it may
|
|
604
|
-
# never reach a terminal status, and the CLI in stream-json mode only
|
|
605
|
-
# exits on stdin EOF — tracking one would withhold the close forever.
|
|
606
|
-
#
|
|
607
|
-
# `background_tasks_changed` is deliberately not consumed, in either
|
|
608
|
-
# direction: its payload is the live *background* set, while a subagent
|
|
609
|
-
# is registered in the foreground and only flips to backgrounded later
|
|
610
|
-
# without a second task_started, so narrowing against the snapshot would
|
|
611
|
-
# drop an agent that goes on to outlive its turn, and widening from it
|
|
612
|
-
# could admit an id no later frame ever clears (observer agents suppress
|
|
613
|
-
# both their start and terminal frames).
|
|
614
|
-
def track_task_lifecycle(message)
|
|
615
|
-
task_id = message[:task_id]
|
|
616
|
-
return if task_id.nil? || task_id.to_s.empty?
|
|
617
|
-
|
|
618
|
-
case message[:subtype]
|
|
619
|
-
when 'task_started'
|
|
620
|
-
@inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
|
|
621
|
-
when 'task_notification'
|
|
622
|
-
@inflight_tasks.delete(task_id)
|
|
623
|
-
when 'task_updated'
|
|
624
|
-
patch = message[:patch]
|
|
625
|
-
status = patch.is_a?(Hash) ? patch[:status] : nil
|
|
626
|
-
@inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
|
|
627
|
-
end
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
# A result ends a turn, not necessarily the run: a background agent that
|
|
631
|
-
# finished just before it still wakes the session for another turn, whose
|
|
632
|
-
# hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
|
|
633
|
-
# CLI that reports session state stays "running" while such a turn is
|
|
634
|
-
# owed, so wait for "idle" (some hosts send it just before the result).
|
|
635
|
-
# Without state events the result is all there is to go on.
|
|
636
|
-
def on_result
|
|
637
|
-
@result_received = true
|
|
638
|
-
@turn_in_progress = false
|
|
639
|
-
if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
|
|
640
|
-
maybe_end_run
|
|
641
|
-
elsif @session_state != 'requires_action'
|
|
642
|
-
# While the SDK is still answering a request the ceiling waits for
|
|
643
|
-
# the "running" that follows.
|
|
644
|
-
arm_run_end_ceiling
|
|
645
|
-
end
|
|
646
|
-
end
|
|
647
|
-
|
|
648
|
-
# Track the CLI's session_state_changed state (Python #1279).
|
|
649
|
-
def on_session_state(state)
|
|
650
|
-
@session_state = state
|
|
651
|
-
if state == 'idle'
|
|
652
|
-
maybe_end_run if @result_received
|
|
653
|
-
return
|
|
654
|
-
end
|
|
655
|
-
# Work the CLI took up after the run ended (a finished background task
|
|
656
|
-
# woke it) reopens the run until the next "idle".
|
|
657
|
-
reopen_run
|
|
658
|
-
if state == 'requires_action'
|
|
659
|
-
# The host is answering a request; stdin must outlast it.
|
|
660
|
-
clear_run_end_ceiling
|
|
661
|
-
else
|
|
662
|
-
rearm_run_end_ceiling_between_turns
|
|
663
|
-
end
|
|
664
|
-
end
|
|
665
|
-
|
|
666
|
-
# End the run unless a tracked background task is still in flight: such
|
|
667
|
-
# a task may still need hook/SDK-MCP control responses over stdin
|
|
668
|
-
# (Python #1088), and its completion wakes the parent for a follow-up
|
|
669
|
-
# turn whose result (or "idle") ends the run then. A CLI that reports
|
|
670
|
-
# session state never reports "idle" with an agent still live, so this
|
|
671
|
-
# matters for CLIs that report "idle" at every turn end or not at all.
|
|
672
|
-
def maybe_end_run
|
|
673
|
-
end_run if @inflight_tasks.empty?
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
# The run is over: wake the stdin-closing waiter. Idempotent.
|
|
677
|
-
#
|
|
678
|
-
# The run is marked ended BEFORE the ceiling is cleared: stopping the
|
|
679
|
-
# sleeper task yields to whatever else is ready, and a #stream_input task
|
|
680
|
-
# that writes its next message in that gap must find the run already
|
|
681
|
-
# ended, so that #reopen_run gives the message a run of its own. Cleared
|
|
682
|
-
# first, the message joined the run that was about to end, and stdin
|
|
683
|
-
# closed before the message's own run had produced a frame.
|
|
684
|
-
def end_run
|
|
685
|
-
@run_end.end!
|
|
686
|
-
clear_run_end_ceiling
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
# Reopen an ended run for work that started after it ended. A waiter the
|
|
690
|
-
# ended run already woke still closes stdin; this makes a later wait
|
|
691
|
-
# (stream_input's, once its prompts are all written) wait for the new
|
|
692
|
-
# work too. Once stdin is closed, or the reader is gone, the run stays
|
|
693
|
-
# ended.
|
|
694
|
-
def reopen_run
|
|
695
|
-
@run_end = RunEnd.new if @run_end.ended? && !@run_final
|
|
696
|
-
end
|
|
697
|
-
|
|
698
|
-
# End the run anyway once the ceiling passes with no new turn. The CLI's
|
|
699
|
-
# own background-wait ceiling only counts once stdin is closed, so without
|
|
700
|
-
# this, work that never finishes would hold "running", and stdin, open
|
|
701
|
-
# forever. It counts only the wait between turns: restarted at each result
|
|
702
|
-
# and whenever the CLI reports "running" again, cleared by main-thread
|
|
703
|
-
# turn activity and by "requires_action", never armed mid-turn.
|
|
704
|
-
#
|
|
705
|
-
# The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
|
|
706
|
-
# result and every "running", and @child_tasks never prunes. The read
|
|
707
|
-
# task's stop cascades to it, and every exit path clears it (#end_run in
|
|
708
|
-
# the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
|
|
709
|
-
# pending sleeper can never keep the enclosing reactor alive.
|
|
710
|
-
def arm_run_end_ceiling
|
|
711
|
-
clear_run_end_ceiling
|
|
712
|
-
# A frame read while close is under way (the result branch flushes the
|
|
713
|
-
# mirror first) must not leave a sleeper behind either.
|
|
714
|
-
return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
|
|
715
|
-
@turn_in_progress || !bidirectional_needs?
|
|
716
|
-
|
|
717
|
-
generation = @run_end_ceiling_generation
|
|
718
|
-
seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
|
|
719
|
-
@run_end_ceiling_task = @task.async do
|
|
720
|
-
sleep seconds
|
|
721
|
-
end_run_at_ceiling(generation)
|
|
722
|
-
end
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
# Restart the ceiling if the run is between turns, past a result, with
|
|
726
|
-
# the CLI still reporting work ("running").
|
|
727
|
-
def rearm_run_end_ceiling_between_turns
|
|
728
|
-
return unless @result_received
|
|
729
|
-
return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
|
|
730
|
-
|
|
731
|
-
arm_run_end_ceiling
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
def end_run_at_ceiling(generation)
|
|
735
|
-
# Cleared or re-armed while this sleeper was already waking up.
|
|
736
|
-
return unless generation == @run_end_ceiling_generation
|
|
737
|
-
|
|
738
|
-
# Detach before ending the run: #end_run clears the ceiling, and
|
|
739
|
-
# clearing it must not stop the task running this very method.
|
|
740
|
-
@run_end_ceiling_task = nil
|
|
741
|
-
# A tracked background agent still running may still need stdin for its
|
|
742
|
-
# hook, permission and SDK MCP requests (Python #1088), so it is not cut
|
|
743
|
-
# off; the ceiling starts over once it settles (#read_messages).
|
|
744
|
-
return unless @inflight_tasks.empty?
|
|
745
|
-
|
|
746
|
-
# A control request the SDK is still answering (a slow hook or SDK MCP
|
|
747
|
-
# tool) must be able to write its reply, so the clock starts over rather
|
|
748
|
-
# than closing stdin under it. Ruby-only guard: Python relies on the CLI
|
|
749
|
-
# reporting requires_action for every such request.
|
|
750
|
-
return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
|
|
751
|
-
|
|
752
|
-
end_run
|
|
753
|
-
end
|
|
754
|
-
|
|
755
|
-
def clear_run_end_ceiling
|
|
756
|
-
@run_end_ceiling_generation += 1
|
|
757
|
-
task = @run_end_ceiling_task
|
|
758
|
-
@run_end_ceiling_task = nil
|
|
759
|
-
task&.stop
|
|
760
|
-
end
|
|
761
|
-
|
|
762
505
|
# Whether the CLI may still send control requests that need a reply.
|
|
763
506
|
#
|
|
764
507
|
# SDK MCP servers, hooks and the can_use_tool permission callback are all
|
|
@@ -777,6 +520,17 @@ module ClaudeAgentSDK
|
|
|
777
520
|
!@sdk_mcp_servers.empty? || !@hooks.empty? || !!@can_use_tool
|
|
778
521
|
end
|
|
779
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
|
+
|
|
780
534
|
# Flush the transcript-mirror batcher, swallowing errors — a mirror failure
|
|
781
535
|
# must never propagate into the read loop or its teardown.
|
|
782
536
|
def flush_transcript_mirror
|
|
@@ -1744,7 +1498,7 @@ module ClaudeAgentSDK
|
|
|
1744
1498
|
# wait BETWEEN turns is bounded: if the CLI still reports "running"
|
|
1745
1499
|
# CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
|
|
1746
1500
|
# limit) after a result with no new turn, the run ends anyway
|
|
1747
|
-
# (#
|
|
1501
|
+
# (RunLifecycle#arm). A turn under way, a request the SDK is still
|
|
1748
1502
|
# answering and a tracked background agent still in flight (Python #1088)
|
|
1749
1503
|
# stop that clock. The run is guaranteed to end: as above, or in
|
|
1750
1504
|
# read_messages' ensure when the process exits early.
|
|
@@ -1755,10 +1509,9 @@ module ClaudeAgentSDK
|
|
|
1755
1509
|
# control requests from that later turn can find stdin closed.
|
|
1756
1510
|
# Single-message and String prompts are fully covered.
|
|
1757
1511
|
def wait_for_result_and_end_input
|
|
1758
|
-
@
|
|
1512
|
+
@run_lifecycle.wait
|
|
1759
1513
|
ensure
|
|
1760
|
-
@
|
|
1761
|
-
clear_run_end_ceiling
|
|
1514
|
+
@run_lifecycle.stdin_closing
|
|
1762
1515
|
@transport.end_input
|
|
1763
1516
|
end
|
|
1764
1517
|
|
|
@@ -1781,9 +1534,7 @@ module ClaudeAgentSDK
|
|
|
1781
1534
|
line = Query.serialize_user_message(message, @verbatim_prompts)
|
|
1782
1535
|
# This message owes a run of its own, result included: an earlier
|
|
1783
1536
|
# one having ended does not end it.
|
|
1784
|
-
|
|
1785
|
-
@result_received = false
|
|
1786
|
-
clear_run_end_ceiling
|
|
1537
|
+
@run_lifecycle.message_will_write
|
|
1787
1538
|
writeln(line)
|
|
1788
1539
|
wrote_message = true
|
|
1789
1540
|
end
|
|
@@ -1819,7 +1570,7 @@ module ClaudeAgentSDK
|
|
|
1819
1570
|
if wrote_message
|
|
1820
1571
|
wait_for_result_and_end_input
|
|
1821
1572
|
else
|
|
1822
|
-
@
|
|
1573
|
+
@run_lifecycle.empty_input
|
|
1823
1574
|
@transport.end_input
|
|
1824
1575
|
end
|
|
1825
1576
|
end
|