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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/docs/hooks-and-permissions.md +22 -1
- data/docs/types.md +32 -11
- data/lib/claude_agent_sdk/command_builder.rb +14 -3
- data/lib/claude_agent_sdk/option_warnings.rb +113 -0
- data/lib/claude_agent_sdk/query.rb +89 -5
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +88 -6
- data/lib/claude_agent_sdk/types.rb +18 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +9 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34802f2c21bd1cf77053b627b48032fa448a616d17379f0c788cee879d12d8ba
|
|
4
|
+
data.tar.gz: 8f44d432159c6fba9dc453a30702d7a215527a5a52ab9398082e3ef53b1bf037
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,
|
|
78
|
-
:duration_ms,
|
|
79
|
-
:duration_api_ms,
|
|
80
|
-
:is_error,
|
|
81
|
-
:num_turns,
|
|
82
|
-
:session_id,
|
|
83
|
-
:stop_reason,
|
|
84
|
-
:total_cost_usd,
|
|
85
|
-
:usage,
|
|
86
|
-
: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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
1114
|
-
#
|
|
1115
|
-
#
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
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
|
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|