claude-agent-sdk 0.23.0 → 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0738568951864f8bf79353f7477f23356c0d04e60900ca346dab0bcc099dbe46'
4
- data.tar.gz: 5136e71504b5078bb3e6fbcc38d34ee2151239be909d275f0972ef40a052a8ff
3
+ metadata.gz: 34802f2c21bd1cf77053b627b48032fa448a616d17379f0c788cee879d12d8ba
4
+ data.tar.gz: 8f44d432159c6fba9dc453a30702d7a215527a5a52ab9398082e3ef53b1bf037
5
5
  SHA512:
6
- metadata.gz: 61d60a87a8202259abc051f7b642d79c3b2c49f1f53a44ad588a69669578d170415860a584ce8e487a81566b1fc51c86a76cb83b0f47f78bd051fdc07e20276e
7
- data.tar.gz: f8a8b46e371a290885f33806fb3a67cf95494ed59003749c54ab68c33123d32f9d80ea3f576262201f08fefa5d14f0d4947edd2245c376a045fbdc10b5f79677
6
+ metadata.gz: 061a1e5c1bea501e9ced4a7a31a893e633b2b5e8f345ae0b28b94959ff2ec5ad2594fd7374be4eefe67bfbcc57d023d364960721f3a81c0f044ee26b57bb35db
7
+ data.tar.gz: 96be886af087aab90fddf8355d858f7e2cf7d31c122e30bee6554a56e20f2a45d970ba80f5026d214a5491beca588cae5c7c03028b49a41868ae0a7172562a04
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-07-27
11
+
12
+ Parity batch with the Python SDK v0.2.111–v0.2.128 (everything substantive in that span; the rest is bundled-CLI version bumps, which don't apply to this gem).
13
+
14
+ ### Fixed
15
+ - **argv flag injection via `resume` / `session_id` / `resume_session_at`** (Python #1123): these values are now passed as single `=`-joined argv tokens (`--resume=<value>`). The CLI declares `--resume [value]` with an *optional* value, so in the old two-token form a dash-leading untrusted value (e.g. a session id taken from a request) was not bound to the flag and parsed as an independent CLI flag — letting it inject e.g. `--dangerously-skip-permissions`. `extra_args` values starting with `-` are likewise emitted in `--flag=value` form (Python #1127); other `extra_args` values keep the two-token form.
16
+ - **Premature stdin close while background tasks are in flight** (Python #1103): a `result` frame ends one *turn*, not the run. The SDK no longer closes stdin on a result while `run_in_background` subagents (task types `local_agent`/`local_workflow`) are still running — previously their SDK-MCP tool calls failed with `"Stream closed"` and their PreToolUse hooks were silently bypassed (deny-gates stopped gating). In-flight tasks are tracked from `task_started` / `task_notification` / terminal `task_updated` lifecycle frames; stdin closes on the first result with none in flight. Background shells/teammates are deliberately not tracked (they may never reach a terminal status, which would withhold the close forever).
17
+ - **Leaked CLI child when a cancellation interrupts `close`** (Python #1082): the graceful TERM→KILL escalation in `SubprocessCLITransport#close` suspends (task sleep, thread join), so `Async::Stop` delivered mid-close abandoned it and left a live `claude` child running until interpreter exit. `close` now guarantees termination from an `ensure` with no suspension points: synchronous SIGTERM immediately, then a plain background thread escalates to SIGKILL after a 2s grace period; on that path the child also deliberately stays in the `at_exit` registry as a second safety net, and the stdin/stdout/stderr pipes are closed best-effort (references cleared first, so even a failed close leaves them collectable) instead of leaking descriptors for the life of the transport object.
18
+
19
+ ### Added
20
+ - `ResultMessage#terminal_reason` (Python #1142): why the query loop ended (`"completed"`, `"max_turns"`, `"aborted_streaming"`, …). `"aborted_streaming"` / `"aborted_tools"` mean the turn was cancelled via `Client#interrupt`. `nil` when the CLI does not report one (older CLIs, or a result that bypassed the query loop such as a local slash command).
21
+ - **Advisory warning when `can_use_tool` is shadowed** (Python #1081): the callback is only consulted when the CLI's permission ladder lands on "ask", so `permission_mode: 'bypassPermissions'` or an `allowed_tools` entry that allows a whole tool (`'Read'`, `'Read()'`, `'Read(*)'` — including the bare `Skill` implied by `skills: 'all'`) silently turns a security callback into dead code. `query()` / `Client#connect` now warn to stderr, once per distinct message per process (`ClaudeAgentSDK::OptionWarnings.reset!` clears the dedupe for tests). Real specifiers (`'Bash(ls:*)'`) and malformed entries don't warn; never raises. The permission-callback docs/example no longer demonstrate the shadowed combination.
22
+ - Documented the `ResultMessage#model_usage` per-model value shape (verbatim CLI camelCase keys, matching the TS/Python `ModelUsage` type), including the new optional `canonicalModel` and `provider` keys (Python #1143 — type-only upstream, so a doc change here).
23
+
10
24
  ## [0.23.0] - 2026-07-14
11
25
 
12
26
  ### Added
data/README.md CHANGED
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.23.0'
71
+ gem 'claude-agent-sdk', '~> 0.24.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
@@ -95,7 +95,6 @@ Async do
95
95
  end
96
96
 
97
97
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(
98
- allowed_tools: ['Read', 'Write', 'Bash'],
99
98
  can_use_tool: permission_callback
100
99
  )
101
100
 
@@ -108,3 +107,25 @@ end.wait
108
107
  ```
109
108
 
110
109
  See [examples/permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb).
110
+
111
+ ### Shadowing: when `can_use_tool` never runs
112
+
113
+ `can_use_tool` is only consulted when the CLI's permission ladder lands on
114
+ "ask". Anything that auto-approves a tool call earlier means the callback
115
+ never fires for it — a security callback can silently become dead code:
116
+
117
+ - `permission_mode: 'bypassPermissions'` auto-approves everything (except
118
+ explicit deny rules), fully shadowing the callback.
119
+ - An `allowed_tools` entry that allows a whole tool — `'Write'`, `'Write()'`,
120
+ `'Write(*)'`, or the bare `Skill` implied by `skills: 'all'` — shadows the
121
+ callback for that tool. A real specifier like `'Bash(ls:*)'` only
122
+ auto-approves matching invocations.
123
+ - Allow rules in settings files shadow the callback the same way, but are not
124
+ visible to the SDK at construction time.
125
+
126
+ The SDK emits an advisory warning to stderr from `query()` / `Client#connect`
127
+ when it can see the shadowing (once per distinct message per process). It
128
+ never raises — shadowing can be intentional, e.g. a callback used solely for
129
+ tools outside `allowed_tools`. To gate every tool call including
130
+ auto-approved ones, use a `PreToolUse` hook instead (note that a `PreToolUse`
131
+ hook returning an allow decision also skips this callback).
data/docs/types.md CHANGED
@@ -74,20 +74,41 @@ Final result message with cost and usage information.
74
74
 
75
75
  ```ruby
76
76
  class ResultMessage
77
- attr_accessor :subtype, # String
78
- :duration_ms, # Integer
79
- :duration_api_ms, # Integer
80
- :is_error, # Boolean
81
- :num_turns, # Integer
82
- :session_id, # String
83
- :stop_reason, # String | nil ('end_turn', 'max_tokens', 'stop_sequence')
84
- :total_cost_usd, # Float | nil
85
- :usage, # Hash | nil
86
- :result, # String | nil (final text result)
87
- :structured_output # Hash | nil (when using output_format)
77
+ attr_accessor :subtype, # String
78
+ :duration_ms, # Integer
79
+ :duration_api_ms, # Integer
80
+ :is_error, # Boolean
81
+ :num_turns, # Integer
82
+ :session_id, # String
83
+ :stop_reason, # String | nil ('end_turn', 'max_tokens', 'stop_sequence')
84
+ :total_cost_usd, # Float | nil
85
+ :usage, # Hash | nil
86
+ :result, # String | nil (final text result)
87
+ :structured_output, # Hash | nil (when using output_format)
88
+ :model_usage, # Hash | nil — { model_name => usage Hash } (see below)
89
+ :permission_denials, # Array | nil
90
+ :errors, # Array<String> | nil (present on error subtypes)
91
+ :uuid, # String | nil
92
+ :fast_mode_state, # String | nil ('off', 'cooldown', 'on')
93
+ :api_error_status, # Integer | nil (HTTP status on api_error subtype)
94
+ :terminal_reason # String | nil (see below)
88
95
  end
89
96
  ```
90
97
 
98
+ `terminal_reason` says why the query loop ended (`"completed"`, `"max_turns"`,
99
+ `"aborted_streaming"`, ...). `"aborted_streaming"` / `"aborted_tools"` mean the
100
+ turn was cancelled via `Client#interrupt`. `nil` when the CLI did not report
101
+ one (older CLIs, or a result that bypassed the query loop such as a local
102
+ slash command).
103
+
104
+ `model_usage` values are passed through verbatim from the CLI, so their keys
105
+ are camelCase (the TypeScript/Python SDKs' `ModelUsage` shape): `inputTokens`,
106
+ `outputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`,
107
+ `webSearchRequests`, `costUSD`, `contextWindow`, `maxOutputTokens`, plus
108
+ optional `canonicalModel` (canonical id used for the pricing lookup, which can
109
+ differ from the raw model-string key for provider-specific ids/aliases) and
110
+ `provider` (`'firstParty'`, `'bedrock'`, `'vertex'`, ...).
111
+
91
112
  ## Content Block Types
92
113
 
93
114
  ```ruby
@@ -142,9 +142,14 @@ module ClaudeAgentSDK
142
142
  raise ArgumentError, "continue_conversation and resume are mutually exclusive" if @options.continue_conversation && @options.resume
143
143
 
144
144
  cmd.push("--continue") if @options.continue_conversation
145
- cmd.push("--resume", @options.resume) if @options.resume
145
+ # =-joined single tokens: the CLI declares `--resume [value]` with an
146
+ # OPTIONAL value, so in the two-token form a dash-leading value is not
147
+ # bound to the flag and parses as an independent CLI flag — letting an
148
+ # untrusted value (e.g. a session id from a request) inject arbitrary
149
+ # flags. The equals form always binds the value to its flag.
150
+ cmd.push("--resume=#{@options.resume}") if @options.resume
146
151
  append_resume_session_at(cmd)
147
- cmd.push("--session-id", @options.session_id) if @options.session_id
152
+ cmd.push("--session-id=#{@options.session_id}") if @options.session_id
148
153
  end
149
154
 
150
155
  # `--resume-session-at <message-uuid>` truncates the resumed conversation
@@ -157,7 +162,9 @@ module ClaudeAgentSDK
157
162
 
158
163
  raise ArgumentError, "resume_session_at requires resume to be set" unless @options.resume
159
164
 
160
- cmd.push("--resume-session-at", @options.resume_session_at.to_s)
165
+ # Equals form for the same reason as --resume above: never let a
166
+ # dash-leading value parse as a separate flag.
167
+ cmd.push("--resume-session-at=#{@options.resume_session_at}")
161
168
  end
162
169
 
163
170
  # Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
@@ -373,6 +380,10 @@ module ClaudeAgentSDK
373
380
 
374
381
  if value.nil?
375
382
  cmd.push("--#{flag}")
383
+ elsif value.to_s.start_with?("-")
384
+ # A dash-leading value must bind via `=` or the CLI parses it as a
385
+ # separate flag — same injection class as --resume above.
386
+ cmd.push("--#{flag}=#{value}")
376
387
  else
377
388
  cmd.push("--#{flag}", value.to_s)
378
389
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ClaudeAgentSDK
6
+ # Advisory warnings for option combinations that silently change behavior.
7
+ module OptionWarnings
8
+ @emitted = Set.new
9
+ @mutex = Mutex.new
10
+
11
+ class << self
12
+ # can_use_tool is only consulted when the CLI's permission ladder lands
13
+ # on "ask". Anything that auto-approves a tool call earlier in the
14
+ # ladder means the callback never runs — so a callback written for
15
+ # *security* purposes can silently be dead code (real callers have
16
+ # shipped path-jails that never fired because the same tools were
17
+ # listed bare in allowed_tools).
18
+ #
19
+ # Advisory only (never raises): shadowing can be intentional, e.g. a
20
+ # callback used solely for tools outside allowed_tools. Deliberately
21
+ # silent on permission_mode 'acceptEdits' (it only auto-approves in-cwd
22
+ # file edits, so warning would be a false positive for anyone gating
23
+ # Bash/network/MCP tools) and on real specifiers like "Bash(ls:*)"
24
+ # (they only shadow matching invocations, not the whole tool).
25
+ def warn_if_can_use_tool_shadowed(options)
26
+ return unless options.can_use_tool
27
+
28
+ message = can_use_tool_shadowed_message(options)
29
+ emit(message) if message
30
+ end
31
+
32
+ # Test hook: forget which messages were already emitted.
33
+ def reset!
34
+ @mutex.synchronize { @emitted.clear }
35
+ end
36
+
37
+ private
38
+
39
+ # Each distinct message is emitted once per process (mirrors Python's
40
+ # default UserWarning filter), so an app constructing a client per
41
+ # request does not repeat the same warning on every connect.
42
+ #
43
+ # Best-effort by design: this runs inside query()/Client#connect, and
44
+ # an advisory warning must never break an otherwise valid session — a
45
+ # daemonized process with a closed or broken $stderr would otherwise
46
+ # turn the warn into an IOError out of connect. The message stays in
47
+ # the dedupe set either way (retrying against a broken sink is
48
+ # pointless and would violate once-per-process).
49
+ def emit(message)
50
+ first = @mutex.synchronize { @emitted.add?(message) }
51
+ return unless first
52
+
53
+ begin
54
+ warn "Claude SDK: #{message}"
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ end
59
+
60
+ def can_use_tool_shadowed_message(options)
61
+ # bypassPermissions shadows the callback for EVERY tool, so it takes
62
+ # precedence over naming individual allowed_tools entries.
63
+ if options.permission_mode == 'bypassPermissions'
64
+ return 'can_use_tool will not be invoked: permission_mode ' \
65
+ "'bypassPermissions' auto-approves every tool call (except " \
66
+ 'explicit deny rules) before the callback is consulted. To ' \
67
+ 'gate every tool call, use a PreToolUse hook instead.'
68
+ end
69
+
70
+ shadowed = shadowed_tools(options)
71
+ return nil if shadowed.empty?
72
+
73
+ "can_use_tool will not be invoked for: #{shadowed.join(', ')}. " \
74
+ 'An allowed_tools entry that allows a whole tool auto-approves it ' \
75
+ 'before the callback is consulted. To gate every tool call, use a ' \
76
+ 'PreToolUse hook; or narrow the entry so calls fall through to ' \
77
+ 'can_use_tool. Allow rules from settings files can also shadow the ' \
78
+ 'callback but are not visible here.'
79
+ end
80
+
81
+ # skills: 'all' makes the command builder append a bare "Skill" to the
82
+ # effective allowed_tools (CommandBuilder#skills_defaults), so it
83
+ # shadows the callback just like a hand-written entry; skills: [names]
84
+ # appends Skill(name) specifiers, which do not.
85
+ def shadowed_tools(options)
86
+ allowed_tools = options.allowed_tools || []
87
+ allowed_tools += ['Skill'] if options.skills == 'all' && !allowed_tools.include?('Skill')
88
+ allowed_tools.filter_map { |entry| whole_tool_allowed(entry) }.uniq
89
+ end
90
+
91
+ # Returns the tool an allowed_tools entry allows outright, else nil.
92
+ #
93
+ # Mirrors the CLI's rule parser (permissionRuleValueFromString): an
94
+ # entry allows a whole tool when it has no (...) specifier ("Read"),
95
+ # or when the specifier is empty or a lone wildcard ("Read()",
96
+ # "Read(*)") — the CLI collapses both to a tool-wide rule. A malformed
97
+ # entry ("Bash(ls:*" without the closing paren, "(*)") falls back to
98
+ # being read as a whole-string tool name by the CLI, which then
99
+ # matches nothing — so it shadows nothing and is ignored here.
100
+ def whole_tool_allowed(entry)
101
+ entry = entry.to_s
102
+ return nil if entry.strip.empty?
103
+
104
+ open_index = entry.index('(')
105
+ return entry if open_index.nil?
106
+ return nil if open_index.zero? || !entry.end_with?(')')
107
+
108
+ specifier = entry[(open_index + 1)...-1]
109
+ ['', '*'].include?(specifier) ? entry[0...open_index] : nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'set'
4
5
  require 'async'
5
6
  require 'async/queue'
6
7
  require 'async/condition'
@@ -23,6 +24,22 @@ module ClaudeAgentSDK
23
24
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
24
25
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
25
26
 
27
+ # Task types whose completion runs a follow-up turn, and which therefore
28
+ # may still need the control channel after the turn's result frame.
29
+ #
30
+ # Mirrors the set the CLI itself holds a result back for, which is
31
+ # narrower than its notion of "delegated agent work". The types left out
32
+ # are left out on purpose:
33
+ # - background shells and monitors run indefinitely by design, so
34
+ # deferring the close on one withholds it forever rather than briefly;
35
+ # - teammates are long-lived too — their status stays running for their
36
+ # whole lifetime, so they never settle the ledger;
37
+ # - remote agents can be long-running monitors the CLI likewise refuses
38
+ # to wait on.
39
+ # Anything added here must be a type that reliably reaches a terminal
40
+ # status, or it will hang the query (see #track_task_lifecycle).
41
+ DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
42
+
26
43
  # Waiter for control responses awaited OFF the reactor — i.e. a control
27
44
  # method called from inside a hook/can_use_tool/SDK-MCP callback, which
28
45
  # runs on a FiberBoundary worker thread (Python supports this reentrancy
@@ -68,7 +85,16 @@ module ClaudeAgentSDK
68
85
 
69
86
  # Message stream
70
87
  @message_queue = Async::Queue.new
88
+ # Set when a run-ending result arrives (a result frame with no tasks in
89
+ # flight) so the stdin-closing waiter can wake. Named for history — it
90
+ # once tracked the literal first result.
71
91
  @first_result_received = false
92
+ # Task IDs of started-but-not-finished deferring tasks. A result frame
93
+ # only ends one turn, not the run: a background task keeps running past
94
+ # it and still needs stdin for hook/SDK-MCP control responses (Python
95
+ # #1088/#1103), so a result that arrives while this set is non-empty
96
+ # must not close stdin.
97
+ @inflight_tasks = Set.new
72
98
  @last_error_result_text = nil
73
99
  @first_result_condition = Async::Condition.new
74
100
  @task = nil
@@ -307,11 +333,21 @@ module ClaudeAgentSDK
307
333
  @transcript_mirror_batcher&.enqueue(message[:filePath] || message[:file_path], message[:entries] || [])
308
334
  next
309
335
  else
336
+ # Track task lifecycle frames so results can tell "one turn ended"
337
+ # apart from "the run is done" (Python #1088/#1103).
338
+ track_task_lifecycle(message) if message[:type] == 'system'
339
+
310
340
  if message[:type] == 'result'
311
341
  # Flush the mirror before signaling/yielding the result so a
312
342
  # consumer observing the result sees an up-to-date store for the turn.
313
343
  flush_transcript_mirror
314
- unless @first_result_received
344
+ # A result with tasks still in flight ends one turn, not the run:
345
+ # the tasks may still need hook/SDK-MCP control responses over
346
+ # stdin, and closing it now silently disables hooks and fails
347
+ # SDK-MCP calls with "Stream closed". Each deferring task's
348
+ # completion wakes the parent for a follow-up turn, so a later
349
+ # result arrives with no tasks in flight and closes stdin then.
350
+ if @inflight_tasks.empty? && !@first_result_received
315
351
  @first_result_received = true
316
352
  @first_result_condition.signal
317
353
  end
@@ -376,6 +412,50 @@ module ClaudeAgentSDK
376
412
  end
377
413
  end
378
414
 
415
+ # Track in-flight tasks from `system` task lifecycle frames.
416
+ #
417
+ # `task_started` marks a task in flight; `task_notification` or a
418
+ # `task_updated` patch with a terminal status clears it. Terminal
419
+ # completion can arrive as either frame (not every terminal task emits a
420
+ # notification), so both are handled; Set deletion keeps the pair
421
+ # idempotent.
422
+ #
423
+ # This is a mitigation, not a complete answer to Python #1088. An empty
424
+ # set means "nothing we know of is running", which is not the same as
425
+ # "the run is over": a task that settles *before* the turn's result frame
426
+ # leaves the set empty at that result, so stdin closes even though the
427
+ # completion may still wake the parent for a continuation turn. What this
428
+ # does fix is the common ordering, where the task outlives the turn that
429
+ # spawned it.
430
+ #
431
+ # Only delegated agent work is tracked (DEFERRING_TASK_TYPES). A
432
+ # background *shell* is also reported through these frames, but it may
433
+ # never reach a terminal status, and the CLI in stream-json mode only
434
+ # exits on stdin EOF — tracking one would withhold the close forever.
435
+ #
436
+ # `background_tasks_changed` is deliberately not consumed, in either
437
+ # direction: its payload is the live *background* set, while a subagent
438
+ # is registered in the foreground and only flips to backgrounded later
439
+ # without a second task_started, so narrowing against the snapshot would
440
+ # drop an agent that goes on to outlive its turn, and widening from it
441
+ # could admit an id no later frame ever clears (observer agents suppress
442
+ # both their start and terminal frames).
443
+ def track_task_lifecycle(message)
444
+ task_id = message[:task_id]
445
+ return if task_id.nil? || task_id.to_s.empty?
446
+
447
+ case message[:subtype]
448
+ when 'task_started'
449
+ @inflight_tasks.add(task_id) if DEFERRING_TASK_TYPES.include?(message[:task_type])
450
+ when 'task_notification'
451
+ @inflight_tasks.delete(task_id)
452
+ when 'task_updated'
453
+ patch = message[:patch]
454
+ status = patch.is_a?(Hash) ? patch[:status] : nil
455
+ @inflight_tasks.delete(task_id) if TERMINAL_TASK_STATUSES.include?(status)
456
+ end
457
+ end
458
+
379
459
  # Flush the transcript-mirror batcher, swallowing errors — a mirror failure
380
460
  # must never propagate into the read loop or its teardown.
381
461
  def flush_transcript_mirror
@@ -1104,15 +1184,19 @@ module ClaudeAgentSDK
1104
1184
  })
1105
1185
  end
1106
1186
 
1107
- # Wait for the first result before closing stdin when hooks or SDK MCP
1187
+ # Wait for a run-ending result before closing stdin when hooks or SDK MCP
1108
1188
  # servers may still need to exchange control messages with the CLI.
1109
1189
  # The control protocol requires stdin to stay open for the entire turn
1110
1190
  # (hook replies, can_use_tool replies and SDK MCP tool results are all
1111
1191
  # written to stdin), so no timeout is applied — closing stdin mid-turn
1112
1192
  # silently broke hooks/MCP on turns longer than the old 60s bound
1113
- # (mirrors Python SDK commit c3d96cb). The condition is guaranteed to be
1114
- # signaled: by the result branch in read_messages, or by its ensure block
1115
- # when the process exits early.
1193
+ # (mirrors Python SDK commit c3d96cb). A result frame ends one turn, not
1194
+ # necessarily the run: while background tasks are in flight the result
1195
+ # branch withholds the signal (Python #1088/#1103), and each deferring
1196
+ # task's completion wakes the parent for a follow-up turn that ends in
1197
+ # another result. The condition is guaranteed to be signaled: by the
1198
+ # result branch in read_messages once no tasks are in flight, or by its
1199
+ # ensure block when the process exits early.
1116
1200
  def wait_for_result_and_end_input
1117
1201
  if !@first_result_received &&
1118
1202
  ((@sdk_mcp_servers && !@sdk_mcp_servers.empty?) || (@hooks && !@hooks.empty?))
@@ -328,6 +328,66 @@ module ClaudeAgentSDK
328
328
  @ready = false
329
329
  return unless @process
330
330
 
331
+ process = @process
332
+ process_teardown_complete = false
333
+ begin
334
+ teardown_process
335
+ process_teardown_complete = true
336
+ ensure
337
+ # The graceful escalation in teardown_process suspends (task sleep,
338
+ # thread join), so a cancellation (Async::Stop) delivered mid-close
339
+ # used to skip TERM/KILL entirely and leak a live CLI child until
340
+ # interpreter exit (the at_exit reaper fires only then, TERM only).
341
+ # Nothing here may suspend: a synchronous TERM plus a plain
342
+ # background thread for the KILL escalation. On this path the
343
+ # process deliberately STAYS in the at_exit registry as a second
344
+ # safety net; the normal path deregisters in teardown_process.
345
+ force_terminate_in_background(process) unless process_teardown_complete
346
+
347
+ # Snapshot-then-nil BEFORE the best-effort pipe close below: once the
348
+ # references are cleared, even a close that somehow failed leaves the
349
+ # IOs unreachable from this (still-referenced) transport, so GC can
350
+ # finalize them — "wait for GC" alone would never fire while the
351
+ # transport keeps pointing at them, and with @process nil a repeat
352
+ # #close returns immediately, so a cancelled close used to leak the
353
+ # pipe descriptors for the life of the object.
354
+ #
355
+ # @stdin is cleared WITHOUT its mutex (taking it could suspend
356
+ # mid-cancellation): the ivar swap is atomic, and #write takes its
357
+ # snapshot under the mutex, so a concurrent writer sees either nil
358
+ # (raises not-ready — @ready is already false) or the old IO, whose
359
+ # in-flight write then fails with IOError and is converted to
360
+ # CLIConnectionError — the documented shutdown behavior either way.
361
+ stdin_io = @stdin
362
+ stdout_io = @stdout
363
+ stderr_io = @stderr
364
+ @process = nil
365
+ @stdin = nil
366
+ @stdout = nil
367
+ @stderr = nil
368
+ @stderr_task = nil
369
+ @exit_error = nil
370
+
371
+ unless process_teardown_complete
372
+ # Cancellation can land before teardown_process reached the pipe
373
+ # closes. stdout/stderr are read ends (close never blocks); stdin's
374
+ # implicit flush is a no-op in practice because #write flushes
375
+ # after every write. Best-effort: an IO that is already closed or
376
+ # fails to close is left to GC, which the nil-ing above enables.
377
+ [stdin_io, stdout_io, stderr_io].each do |io|
378
+ io&.close
379
+ rescue StandardError
380
+ nil
381
+ end
382
+ end
383
+ end
384
+ end
385
+
386
+ # Pre-existing #close body: stop the stderr drain, close pipes, wait for
387
+ # graceful exit after stdin EOF, escalate TERM → KILL on timeout. Runs on
388
+ # the reactor and suspends at several points; #close's ensure covers the
389
+ # cancellation-abandoned case.
390
+ def teardown_process
331
391
  cleanup_errors = []
332
392
 
333
393
  # Kill stderr thread
@@ -404,12 +464,34 @@ module ClaudeAgentSDK
404
464
  end
405
465
 
406
466
  self.class.deregister_active_process(@process)
407
- @process = nil
408
- @stdout = nil
409
- # @stdin already nilled under the mutex above.
410
- @stderr = nil
411
- @stderr_task = nil
412
- @exit_error = nil
467
+ end
468
+
469
+ # Last-resort termination when #close was interrupted before the graceful
470
+ # escalation finished. Contains no suspension points, so it is safe
471
+ # inside an ensure during fiber cancellation: synchronous SIGTERM now,
472
+ # then a plain (non-reactor) thread escalates to SIGKILL after a grace
473
+ # period. Open3's Process::Waiter thread keeps reaping, so no zombie is
474
+ # left either way. The alive? guard also makes the delayed KILL
475
+ # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
476
+ # the pid cannot have been recycled.
477
+ def force_terminate_in_background(process, grace_seconds: 2)
478
+ return unless process&.alive?
479
+
480
+ pid = process.pid
481
+ begin
482
+ Process.kill('TERM', pid)
483
+ rescue StandardError
484
+ return # ESRCH: already gone; EPERM: not ours to signal
485
+ end
486
+
487
+ Thread.new do
488
+ sleep grace_seconds
489
+ begin
490
+ Process.kill('KILL', pid) if process.alive?
491
+ rescue StandardError
492
+ nil # died inside the grace window
493
+ end
494
+ end
413
495
  end
414
496
 
415
497
  # Wait for the spawned process to exit, up to +timeout_seconds+. Polls
@@ -409,15 +409,31 @@ module ClaudeAgentSDK
409
409
 
410
410
  # Result message with cost and usage information
411
411
  class ResultMessage < Type
412
+ # model_usage maps model name => per-model usage Hash, passed through
413
+ # verbatim from the CLI's modelUsage field, so its keys are camelCase
414
+ # (matches the TypeScript/Python SDKs' ModelUsage shape): inputTokens,
415
+ # outputTokens, cacheReadInputTokens, cacheCreationInputTokens,
416
+ # webSearchRequests, costUSD, contextWindow, maxOutputTokens, plus
417
+ # optional canonicalModel (canonical id used for the pricing lookup —
418
+ # may differ from the raw model-string key for provider-specific
419
+ # ids/aliases) and provider ('firstParty', 'bedrock', 'vertex', ...).
420
+ #
421
+ # terminal_reason says why the query loop ended ("completed",
422
+ # "max_turns", "aborted_streaming", ...). "aborted_streaming" /
423
+ # "aborted_tools" mean the turn was cancelled via Client#interrupt (an
424
+ # interrupt control request). nil when the CLI did not report one
425
+ # (older CLI versions, or a result that bypassed the query loop such
426
+ # as a local slash command).
412
427
  attr_accessor :subtype, :duration_ms, :duration_api_ms, :is_error,
413
428
  :num_turns, :session_id, :stop_reason, :total_cost_usd, :usage,
414
429
  :result, :structured_output,
415
- :model_usage, # Hash of { model_name => usage_data }
430
+ :model_usage, # Hash of { model_name => usage_data }, see above
416
431
  :permission_denials, # Array of { tool_name:, tool_use_id:, tool_input: }
417
432
  :errors, # Array of error strings (present on error subtypes)
418
433
  :uuid,
419
434
  :fast_mode_state, # "off", "cooldown", or "on"
420
- :api_error_status # Integer HTTP status (429, 500, 529) on api_error subtype (CLI 2.1.110+)
435
+ :api_error_status, # Integer HTTP status (429, 500, 529) on api_error subtype (CLI 2.1.110+)
436
+ :terminal_reason # why the query loop ended, see above
421
437
 
422
438
  attr_reader :deferred_tool_use # DeferredToolUse, populated when a PreToolUse hook deferred
423
439
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.23.0'
4
+ VERSION = '0.24.0'
5
5
  end
@@ -18,6 +18,7 @@ require_relative 'claude_agent_sdk/transcript_mirror_batcher'
18
18
  require_relative 'claude_agent_sdk/session_resume'
19
19
  require_relative 'claude_agent_sdk/session_mutations'
20
20
  require_relative 'claude_agent_sdk/fiber_boundary'
21
+ require_relative 'claude_agent_sdk/option_warnings'
21
22
  require 'async'
22
23
  require 'securerandom'
23
24
 
@@ -411,6 +412,10 @@ module ClaudeAgentSDK
411
412
  configured_options = options.dup_with(permission_prompt_tool_name: 'stdio')
412
413
  end
413
414
 
415
+ # Advisory: warn if other options shadow the can_use_tool callback.
416
+ # After the ArgumentError validations so invalid configs raise, not warn.
417
+ OptionWarnings.warn_if_can_use_tool_shadowed(options)
418
+
414
419
  # Fail fast on invalid session_store combinations before spawning the CLI.
415
420
  SessionStores.validate_session_store_options(configured_options)
416
421
 
@@ -680,6 +685,10 @@ module ClaudeAgentSDK
680
685
  configured_options = @options.dup_with(permission_prompt_tool_name: 'stdio')
681
686
  end
682
687
 
688
+ # Advisory: warn if other options shadow the can_use_tool callback.
689
+ # After the ArgumentError validations so invalid configs raise, not warn.
690
+ OptionWarnings.warn_if_can_use_tool_shadowed(@options)
691
+
683
692
  # Fail fast on invalid session_store combinations before spawning the CLI.
684
693
  # Configuration validation is a usage error, like the ArgumentErrors
685
694
  # above — deliberately outside the on_error notify scope.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-14 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -129,6 +129,7 @@ files:
129
129
  - lib/claude_agent_sdk/instrumentation/otel.rb
130
130
  - lib/claude_agent_sdk/message_parser.rb
131
131
  - lib/claude_agent_sdk/observer.rb
132
+ - lib/claude_agent_sdk/option_warnings.rb
132
133
  - lib/claude_agent_sdk/query.rb
133
134
  - lib/claude_agent_sdk/sdk_mcp_server.rb
134
135
  - lib/claude_agent_sdk/session_mutations.rb