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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0738568951864f8bf79353f7477f23356c0d04e60900ca346dab0bcc099dbe46'
4
- data.tar.gz: 5136e71504b5078bb3e6fbcc38d34ee2151239be909d275f0972ef40a052a8ff
3
+ metadata.gz: 48fe05a71686f61555d5985ac8fe92e5d08b1ac530f1c2e2bb071f4d6b0fdb81
4
+ data.tar.gz: 669eedb9627e578e893e3e214271c172a0169d07fd9f60493c5c2d47c5540acb
5
5
  SHA512:
6
- metadata.gz: 61d60a87a8202259abc051f7b642d79c3b2c49f1f53a44ad588a69669578d170415860a584ce8e487a81566b1fc51c86a76cb83b0f47f78bd051fdc07e20276e
7
- data.tar.gz: f8a8b46e371a290885f33806fb3a67cf95494ed59003749c54ab68c33123d32f9d80ea3f576262201f08fefa5d14f0d4947edd2245c376a045fbdc10b5f79677
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.23.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. 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:
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, # 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
@@ -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 enforced in plain synchronous code
63
- # too; JoinTimeout is raised when it expires.
64
- def invoke(timeout: nil, &block)
65
- return block.call if timeout.nil? && !Fiber.scheduler
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 place and `break` unwinds
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