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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +99 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +83 -108
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- 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 +266 -364
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_assembly.rb +311 -0
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +360 -43
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +223 -418
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- 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
|
-
# (#
|
|
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
|
|
140
|
-
# method called from inside a hook/can_use_tool/SDK-MCP
|
|
141
|
-
# runs on a FiberBoundary worker thread (Python supports
|
|
142
|
-
# natively: callbacks are event-loop tasks and
|
|
143
|
-
# level-triggered)
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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
|
-
#
|
|
197
|
-
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
|
|
201
|
-
@
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
369
|
-
# alive, and is stopped automatically
|
|
370
|
-
#
|
|
371
|
-
#
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
487
|
-
#
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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 !
|
|
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
|
-
@
|
|
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
|
|
849
|
-
#
|
|
850
|
-
#
|
|
851
|
-
#
|
|
852
|
-
def
|
|
853
|
-
message =
|
|
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
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1320
|
-
#
|
|
1321
|
-
#
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
# (#
|
|
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
|
-
@
|
|
1512
|
+
@run_lifecycle.wait
|
|
1676
1513
|
ensure
|
|
1677
|
-
@
|
|
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
|
-
|
|
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
|
|
1721
|
-
#
|
|
1722
|
-
#
|
|
1723
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
1773
|
-
# or no live
|
|
1774
|
-
# dead, so stopping them no
|
|
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
|
|
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
|
-
#
|
|
1933
|
-
#
|
|
1934
|
-
#
|
|
1935
|
-
#
|
|
1936
|
-
#
|
|
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
|