claude-agent-sdk 0.23.0 → 0.25.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 +20 -0
- data/README.md +2 -2
- data/docs/hooks-and-permissions.md +22 -1
- data/docs/rails.md +46 -1
- data/docs/types.md +32 -11
- data/lib/claude_agent_sdk/command_builder.rb +14 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +84 -8
- data/lib/claude_agent_sdk/option_warnings.rb +113 -0
- data/lib/claude_agent_sdk/query.rb +150 -18
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +38 -7
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +88 -6
- data/lib/claude_agent_sdk/types.rb +52 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +82 -24
- 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: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
|
|
4
|
+
data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7efcdba28fd54e81ddbf4b24ae01e14fde11c9c111f39241f65901eb23453d938e4e1be0fee3493fc034f0e81f0ec8373664d68a061d029b280e01f2a7ed4ad0
|
|
7
|
+
data.tar.gz: 2fb7a50378cb0bd44408b2d31737bd4fc1ec51162473e1417dcd1be58c851b5e369c9de9313edfa48425a5e36d058ccade91cf47bf5681251eeb9fa3c2a89031
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.25.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Opt-in fiber-native callback execution: `callback_scheduling: :inline`** (#47). By default the SDK hops every user callback (message blocks, hooks, permission callbacks, SDK MCP handlers, observers) to a plain thread so thread-keyed libraries (ActiveRecord, pg, …) never see the async gem's Fiber scheduler. Hosts that are fiber-isolated end to end — e.g. solid_queue fiber workers (`fibers: N`) with `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` — can now pass `ClaudeAgentOptions.new(callback_scheduling: :inline)` (or set it globally via `ClaudeAgentSDK.configure`) to run callbacks in place on the reactor fiber: `Fiber.scheduler` is live inside callbacks, no per-call threads exist to strand AR connections, and the session can live directly on the job fiber. This matches the Python SDK's execution model (async callbacks run natively on the event loop). Default behavior is unchanged. In `:inline` mode hook timeouts become cooperative cancellations (the hook is interrupted at its next suspension point and its `ensure` blocks run) instead of hard thread abandonment; `SessionStore` adapter calls intentionally stay on threads so a wedged adapter can never stall the reactor. The SDK warns once when `:inline` is enabled under `isolation_level == :thread`. See "Fiber workers (solid_queue)" in docs/rails.md.
|
|
14
|
+
- `ClaudeAgentSDK.offload { }` — public escape hatch for `:inline` hosts: runs a heavy piece of a callback on a plain thread instead of the reactor fiber. Shields the reactor from scheduler-opaque blocking that releases the GVL (native DB drivers, file/socket I/O); turns pure-Ruby CPU work into GVL time-slicing instead of a hard stall. A C extension that holds the GVL for the whole computation still freezes the process — move such work to a subprocess. No-op outside a reactor.
|
|
15
|
+
|
|
16
|
+
## [0.24.0] - 2026-07-27
|
|
17
|
+
|
|
18
|
+
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).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **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.
|
|
22
|
+
- **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).
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `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).
|
|
27
|
+
- **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.
|
|
28
|
+
- 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).
|
|
29
|
+
|
|
10
30
|
## [0.23.0] - 2026-07-14
|
|
11
31
|
|
|
12
32
|
### 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.25.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
@@ -210,7 +210,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
210
210
|
| Structured output, thinking config, budget, fallback model, advisor model, beta features, sandbox, bare mode, file checkpointing | [docs/configuration.md](docs/configuration.md) |
|
|
211
211
|
| Session listing, reading, renaming, tagging, deleting, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
|
|
212
212
|
| OpenTelemetry tracing, Langfuse setup, custom observers | [docs/observability.md](docs/observability.md) |
|
|
213
|
-
| Rails integration (fiber safety, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
|
|
213
|
+
| Rails integration (fiber safety, solid_queue fiber workers / `callback_scheduling: :inline`, ActionCable, sessions, jobs, HTTP MCP, observability initializer) | [docs/rails.md](docs/rails.md) |
|
|
214
214
|
| Message, content block, and configuration type reference | [docs/types.md](docs/types.md) |
|
|
215
215
|
| Error handling, exception hierarchy, timeout configuration | [docs/errors.md](docs/errors.md) |
|
|
216
216
|
|
|
@@ -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/rails.md
CHANGED
|
@@ -6,7 +6,7 @@ The SDK integrates well with Rails applications. Below are the common patterns.
|
|
|
6
6
|
|
|
7
7
|
The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
|
|
8
8
|
|
|
9
|
-
You do **not** need to think about this.
|
|
9
|
+
You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
|
|
@@ -21,6 +21,51 @@ end
|
|
|
21
21
|
|
|
22
22
|
The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
|
|
23
23
|
|
|
24
|
+
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
25
|
+
|
|
26
|
+
[solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/application.rb
|
|
30
|
+
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On Rails 7.2+, ActiveRecord releases connections between queries under fiber isolation, so fiber counts can far exceed the pool size (e.g. 50 fibers on 25 connections).
|
|
34
|
+
|
|
35
|
+
In such a host the default thread hop works *against* you: every callback is ejected from the reactor onto a fresh bare thread, where `Fiber.scheduler` is `nil` (reactor APIs like `Async::Task#stop` / `Async::Notification` are unusable), and an implicitly checked-out AR connection dies with the throwaway thread (stranded until the reaper reclaims it). For these hosts the SDK offers opt-in inline scheduling:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/initializers/claude_agent_sdk.rb — process-wide, matching
|
|
39
|
+
# isolation_level's process-wide nature. Only set this in processes that run
|
|
40
|
+
# fiber workers; or pass it per-session via ClaudeAgentOptions instead.
|
|
41
|
+
ClaudeAgentSDK.configure do |config|
|
|
42
|
+
config.default_options = { callback_scheduling: :inline }
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely:
|
|
47
|
+
|
|
48
|
+
- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs.
|
|
49
|
+
- No per-call threads exist, so nothing can strand an AR connection.
|
|
50
|
+
- The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware.
|
|
51
|
+
- Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out.
|
|
52
|
+
- The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
|
|
53
|
+
|
|
54
|
+
The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-bound work or a GVL-holding C extension inside an inline callback blocks every job on that worker, not just yours. Blocking that releases the GVL and pure-Ruby CPU work can be moved onto a thread explicitly:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
|
|
58
|
+
row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
|
|
59
|
+
{ content: [{ type: 'text', text: row.to_json }] }
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`ClaudeAgentSDK.offload` is a no-op outside a reactor, so it's safe to call unconditionally. Be precise about what it protects, though: it fully shields the reactor from blocking calls that *release* the GVL (native DB drivers, file/socket I/O the scheduler can't see), and it turns pure-Ruby CPU work from a hard stall into GVL time-slicing (added latency for sibling jobs, not starvation). A C extension that **holds** the GVL for the whole computation still freezes the process — `offload` cannot help there; run that work in a subprocess.
|
|
64
|
+
|
|
65
|
+
Preconditions, spelled out: `:inline` is only correct when the process satisfies the same requirements as solid_queue's fiber workers — `isolation_level = :fiber`, Rails 7.2+ for AR, and no thread-keyed libraries used inside callbacks without a fiber-aware wrapper. The SDK warns once if it detects `:inline` under `isolation_level == :thread`. Everything else (Puma request threads, threaded Sidekiq/solid_queue workers) should stay on the default `callback_scheduling: :thread`.
|
|
66
|
+
|
|
67
|
+
Note that `SessionStore` adapter calls (`#append` / `#load`) intentionally stay on threads even in `:inline` mode — their timeouts are hard bounds (`Thread#join`) so a wedged store adapter can never stall the reactor.
|
|
68
|
+
|
|
24
69
|
## ActionCable Streaming
|
|
25
70
|
|
|
26
71
|
Stream Claude responses to the frontend in real-time:
|
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
|
|
@@ -24,6 +24,26 @@ module ClaudeAgentSDK
|
|
|
24
24
|
#
|
|
25
25
|
# No-op when no scheduler is active, so it's cheap to use unconditionally.
|
|
26
26
|
#
|
|
27
|
+
# OPT-IN INLINE MODE: hosts that are already fiber-isolated (e.g. Rails
|
|
28
|
+
# apps with `IsolatedExecutionState.isolation_level = :fiber` running
|
|
29
|
+
# solid_queue fiber workers) can pass `scheduling: :inline` to run
|
|
30
|
+
# callbacks in place on the reactor fiber instead of hopping. This is the
|
|
31
|
+
# same code path as the no-scheduler case — semantically the already-
|
|
32
|
+
# shipped synchronous path, and Python SDK parity (async callbacks run
|
|
33
|
+
# natively on the event loop there). The mode is plumbed per-call from
|
|
34
|
+
# `ClaudeAgentOptions#callback_scheduling` — never a thread-local (the
|
|
35
|
+
# reactor thread is shared by many fibers, so a set/reset window across
|
|
36
|
+
# suspension points would leak across sessions). The one path a call
|
|
37
|
+
# argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
|
|
38
|
+
# it via a scoped, invalidatable fiber-storage entry instead — see
|
|
39
|
+
# SCHEDULING_KEY / SchedulingScope below.
|
|
40
|
+
#
|
|
41
|
+
# A `timeout:` always forces the thread hop regardless of scheduling —
|
|
42
|
+
# `Thread#join(timeout)` is a hard bound that can abandon a wedged call,
|
|
43
|
+
# which cooperative `with_timeout` cancellation cannot guarantee. The
|
|
44
|
+
# store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely
|
|
45
|
+
# on this to never stall the reactor on a wedged adapter.
|
|
46
|
+
#
|
|
27
47
|
# The thread hop severs `break`/`return`/`next` from the surrounding method,
|
|
28
48
|
# so SDK loops yielding user callbacks must keep loop control outside the
|
|
29
49
|
# invoked block (see `Client#receive_response`); user-initiated `break` is
|
|
@@ -44,6 +64,57 @@ module ClaudeAgentSDK
|
|
|
44
64
|
# in-flight call may still complete).
|
|
45
65
|
class JoinTimeout < StandardError; end
|
|
46
66
|
|
|
67
|
+
# Cancellation injected into INLINE user callbacks by timeout
|
|
68
|
+
# enforcement (hook timeouts under scheduling: :inline) — user code
|
|
69
|
+
# should let it propagate. Deliberately
|
|
70
|
+
# NOT a StandardError: the exception is raised inside user code at a
|
|
71
|
+
# suspension point, and a callback's ordinary `rescue StandardError`
|
|
72
|
+
# must not be able to swallow the cancellation and convert an expired
|
|
73
|
+
# hook into a success (Async::TimeoutError is a StandardError, so it
|
|
74
|
+
# cannot be injected directly). The SDK translates it back to
|
|
75
|
+
# Async::TimeoutError once control returns from user code.
|
|
76
|
+
# @api private
|
|
77
|
+
class InlineCancellation < Exception; end # rubocop:disable Lint/InheritException
|
|
78
|
+
|
|
79
|
+
# Fiber-storage key carrying the dispatching session's callback
|
|
80
|
+
# scheduling mode across the SDK-MCP dispatch path (Query ->
|
|
81
|
+
# MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so
|
|
82
|
+
# concurrent sessions with different modes sharing one SdkMcpServer
|
|
83
|
+
# instance cannot cross-contaminate — unlike mutating the shared
|
|
84
|
+
# server (last-writer-wins, persists past close) or a thread-local
|
|
85
|
+
# (the reactor thread is shared by many fibers).
|
|
86
|
+
# @api private
|
|
87
|
+
SCHEDULING_KEY = :claude_agent_sdk_callback_scheduling
|
|
88
|
+
|
|
89
|
+
# The value stored under SCHEDULING_KEY: a
|
|
90
|
+
# closable carrier rather than a bare symbol. Fiber-storage inheritance
|
|
91
|
+
# copies the storage HASH but shares value REFERENCES, so every fiber
|
|
92
|
+
# (and thread) created during a dispatch inherits this same object.
|
|
93
|
+
# Restoring the dispatching fiber's own slot is therefore not enough —
|
|
94
|
+
# a child task spawned inside a handler that outlives the dispatch
|
|
95
|
+
# would keep reading the stale mode forever. Closing the scope in the
|
|
96
|
+
# dispatch's ensure invalidates it for ALL inheritors at once; readers
|
|
97
|
+
# fall back to the server's own default. Benign race on close vs. a
|
|
98
|
+
# concurrent reader: either value is a defensible mode for a call that
|
|
99
|
+
# straddles the dispatch boundary.
|
|
100
|
+
# @api private
|
|
101
|
+
class SchedulingScope
|
|
102
|
+
attr_reader :mode
|
|
103
|
+
|
|
104
|
+
def initialize(mode)
|
|
105
|
+
@mode = mode
|
|
106
|
+
@active = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def active?
|
|
110
|
+
@active
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def close
|
|
114
|
+
@active = false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
47
118
|
# Sentinel returned by .invoke_iteration when the user block attempted `break`.
|
|
48
119
|
class Break
|
|
49
120
|
attr_reader :value
|
|
@@ -59,10 +130,15 @@ module ClaudeAgentSDK
|
|
|
59
130
|
# Returns the block's value. Exceptions propagate to the caller.
|
|
60
131
|
#
|
|
61
132
|
# With +timeout+ (seconds) the thread hop happens unconditionally — even
|
|
62
|
-
# without a scheduler — so the bound is
|
|
63
|
-
# too; JoinTimeout is raised when it
|
|
64
|
-
|
|
65
|
-
|
|
133
|
+
# without a scheduler or with `scheduling: :inline` — so the bound is
|
|
134
|
+
# enforced in plain synchronous code too; JoinTimeout is raised when it
|
|
135
|
+
# expires.
|
|
136
|
+
#
|
|
137
|
+
# With `scheduling: :inline` (and no timeout) the block runs in place on
|
|
138
|
+
# the current fiber, scheduler or not. The caller opts in via
|
|
139
|
+
# `ClaudeAgentOptions#callback_scheduling`.
|
|
140
|
+
def invoke(timeout: nil, scheduling: :thread, &block)
|
|
141
|
+
return block.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)
|
|
66
142
|
|
|
67
143
|
thread = Thread.new(&block)
|
|
68
144
|
thread.report_on_exception = false
|
|
@@ -77,10 +153,10 @@ module ClaudeAgentSDK
|
|
|
77
153
|
# LocalJumpError(reason: :break) on the worker thread; translate it into
|
|
78
154
|
# a Break sentinel so the SDK loop can break on the calling fiber.
|
|
79
155
|
# Returns Break when the user broke, nil when the block completed.
|
|
80
|
-
# Without a scheduler the block runs in
|
|
81
|
-
# natively, never reaching the translation.
|
|
82
|
-
def invoke_iteration(block, *args)
|
|
83
|
-
invoke do
|
|
156
|
+
# Without a scheduler (or with `scheduling: :inline`) the block runs in
|
|
157
|
+
# place and `break` unwinds natively, never reaching the translation.
|
|
158
|
+
def invoke_iteration(block, *args, scheduling: :thread)
|
|
159
|
+
invoke(scheduling: scheduling) do
|
|
84
160
|
block.call(*args)
|
|
85
161
|
nil
|
|
86
162
|
rescue LocalJumpError => e
|
|
@@ -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
|