claude-agent-sdk 0.33.1 → 0.35.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +54 -19
  4. data/docs/cli-installer.md +40 -9
  5. data/docs/client.md +27 -18
  6. data/docs/configuration.md +5 -5
  7. data/docs/errors.md +4 -3
  8. data/docs/mcp-servers.md +22 -0
  9. data/docs/observability.md +6 -0
  10. data/docs/rails.md +92 -51
  11. data/docs/sessions.md +66 -15
  12. data/lib/claude_agent_sdk/cli_installer.rb +34 -18
  13. data/lib/claude_agent_sdk/command_builder.rb +11 -3
  14. data/lib/claude_agent_sdk/configuration.rb +54 -2
  15. data/lib/claude_agent_sdk/errors.rb +11 -3
  16. data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
  17. data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
  18. data/lib/claude_agent_sdk/query.rb +140 -59
  19. data/lib/claude_agent_sdk/railtie.rb +94 -0
  20. data/lib/claude_agent_sdk/sdk_mcp_server.rb +46 -4
  21. data/lib/claude_agent_sdk/session_mutations.rb +39 -12
  22. data/lib/claude_agent_sdk/session_resume.rb +112 -39
  23. data/lib/claude_agent_sdk/session_store.rb +19 -3
  24. data/lib/claude_agent_sdk/session_summary.rb +5 -5
  25. data/lib/claude_agent_sdk/sessions.rb +123 -55
  26. data/lib/claude_agent_sdk/streaming.rb +0 -8
  27. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
  28. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +30 -0
  29. data/lib/claude_agent_sdk/tasks.rb +13 -0
  30. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
  31. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
  32. data/lib/claude_agent_sdk/types.rb +349 -39
  33. data/lib/claude_agent_sdk/version.rb +1 -1
  34. data/lib/claude_agent_sdk.rb +53 -44
  35. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  36. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  37. metadata +29 -13
data/docs/rails.md CHANGED
@@ -1,6 +1,49 @@
1
1
  # Rails Integration
2
2
 
3
- The SDK integrates well with Rails applications. Below are the common patterns.
3
+ The gem ships a Railtie, an install generator and a rake task for vendoring the CLI; the rest of this page covers how SDK callbacks interact with Rails' threading, executor and fiber workers, and the common job / ActionCable patterns.
4
+
5
+ ## Getting started
6
+
7
+ 1. Add the gem:
8
+
9
+ ```bash
10
+ bundle add claude-agent-sdk
11
+ ```
12
+
13
+ 2. Generate the initializer:
14
+
15
+ ```bash
16
+ bin/rails generate claude_agent_sdk:install
17
+ ```
18
+
19
+ This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`.
20
+
21
+ 3. Vendor the Claude Code CLI:
22
+
23
+ ```bash
24
+ bin/rails claude_agent_sdk:install_cli # the version this gem release is tested with
25
+ bin/rails claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # or a version of your own ('stable' / 'latest' float)
26
+ ```
27
+
28
+ The binary lands in `Rails.root/vendor/claude`, where the SDK finds it ahead of any `claude` on `PATH` whenever the process runs from the app root, as `bin/rails`, Puma and most job runners do (otherwise set `cli_path:`; the initializer has it commented). The task does not boot the app (no database or credentials needed), so the same line works as a cached Docker build step: `RUN bin/rails claude_agent_sdk:install_cli`. Installs are checksum-verified and idempotent — see [docs/cli-installer.md](cli-installer.md). The CLI authenticates from the environment, e.g. `ANTHROPIC_API_KEY`.
29
+
30
+ 4. Run an agent from a job:
31
+
32
+ ```ruby
33
+ # app/jobs/summarize_ticket_job.rb
34
+ class SummarizeTicketJob < ApplicationJob
35
+ def perform(ticket)
36
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1) # text only, no built-in tools
37
+ prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
38
+
39
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
40
+ ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
41
+ end
42
+ end
43
+ end
44
+ ```
45
+
46
+ The block runs on a plain thread (see the next section), so ActiveRecord calls inside it just work. For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below.
4
47
 
5
48
  ## Thread-keyed libraries are safe inside SDK callbacks
6
49
 
@@ -23,17 +66,25 @@ The trade-off: because callbacks run on a plain thread rather than inside an `As
23
66
 
24
67
  ### Rails executor around callbacks: `callback_wrapper`
25
68
 
26
- One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch:
69
+ One consequence of the thread hop: an ActiveRecord connection implicitly checked out inside a callback belongs to that throwaway thread and stays stranded until the pool reaper reclaims it. Rails' own answer to "code running on a thread Rails didn't create" is the executor — and `callback_wrapper` lets you install it around every user-callback dispatch. Use the SDK's Rails-aware wrapper (the generated initializer already does):
27
70
 
28
71
  ```ruby
29
72
  ClaudeAgentSDK.configure do |config|
30
73
  config.default_options = {
31
- callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
74
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
32
75
  }
33
76
  end
34
77
  ```
35
78
 
36
- The wrapper is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc.
79
+ It runs each callback inside `Rails.application.executor.wrap` — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend:
80
+
81
+ - **Development (code reloading enabled).** Every executor then holds a share of the code-reload interlock. The request or job calling the SDK is already inside the executor, and in `:thread` mode it waits for the callback's thread. If a reload is requested meanwhile (say, another request arrives after the agent edited an app file), the reloader queues for the exclusive unload lock, and a callback thread entering `executor.wrap` queues behind it for a fresh share — which the reloader can never let through while the waiting caller holds its own. Everything hangs. The helper instead runs the callback without entering the executor (the caller's share still keeps code from being unloaded under it) and returns the thread's ActiveRecord connections to the pool when the callback finishes. The executor's other per-run hooks (query cache, `CurrentAttributes` reset) do not run for callbacks in this case.
82
+ - **`config.allow_concurrency = false`.** The executor holds a process-wide monitor that the calling thread already owns, so a callback thread's `executor.wrap` would block every time; same treatment.
83
+ - **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor.
84
+
85
+ Everywhere else — production, with no reloading — it is exactly `executor.wrap`. The configuration is read per call, so one initializer is correct in every environment.
86
+
87
+ Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc. — to combine one with the Rails wrapper, call it from yours: `rails = ClaudeAgentSDK::Railtie.callback_wrapper` then `->(inv) { MyApm.trace { rails.call(inv) } }`.
37
88
 
38
89
  The wrapper also composes around every timeout-bounded `SessionStore` adapter call (mirror-batcher appends, resume-materialization loads and listings), inside the timeout bound — so an ActiveRecord-backed store adapter gets the same connection hygiene as your callbacks.
39
90
 
@@ -67,7 +118,9 @@ With `:inline`, every user callback — message blocks, hooks, permission callba
67
118
  - No per-call threads exist, so nothing can strand an AR connection.
68
119
  - 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.
69
120
  - 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.
121
+ - A cooperative deadline bounds the cancellation *request*, not the callback's completion: the cancellation is delivered once, and whatever the callback's `ensure` does afterwards runs unbounded on the reactor fiber. Fiber-aware cleanup delays only that callback (and the CLI waiting on its reply); scheduler-opaque cleanup — a file `fsync`, a non-fiber-aware driver, a GVL-holding C extension — stalls every job on the reactor for as long as it takes, and no deadline can interrupt it. Keep inline cleanup fiber-aware, or stay on `:thread` scheduling when you need a hard bound on the whole invocation.
70
122
  - The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
123
+ - Calling `client.disconnect` from inside an inline **control-request callback** (a hook, `can_use_tool`, or an SDK MCP handler) works, with one difference from `:thread` mode: the callback's own task is a child of the read task that `disconnect` stops, so after the teardown has completed (transport closed, pending control waiters released) the deferred `Async::Stop` unwinds the callback — `disconnect` raises there instead of returning. Put cleanup in `ensure`; a `rescue StandardError` will not see it (it is not a `StandardError`). In `:thread` mode `disconnect` returns normally on the worker thread and the callback's return value is simply dropped. Either way the callback's response is never sent — the session is gone. Message blocks and observers run on the caller's task, not under the read task, so a `disconnect` from one of those returns normally in both modes; a streaming-input enumerator that calls `disconnect` unwinds with `Async::Stop` in both modes.
71
124
 
72
125
  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:
73
126
 
@@ -94,38 +147,33 @@ class ChatAgentJob < ApplicationJob
94
147
  queue_as :claude_agents
95
148
 
96
149
  def perform(chat_id, message_content)
97
- Async do
98
- options = ClaudeAgentSDK::ClaudeAgentOptions.new(
99
- system_prompt: { type: 'preset', preset: 'claude_code' },
100
- permission_mode: 'bypassPermissions'
101
- )
150
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
151
+ system_prompt: { type: 'preset', preset: 'claude_code' },
152
+ permission_mode: 'bypassPermissions'
153
+ )
102
154
 
103
- client = ClaudeAgentSDK::Client.new(options: options)
104
-
105
- begin
106
- client.connect
107
- client.query(message_content)
108
-
109
- client.receive_response do |message|
110
- case message
111
- when ClaudeAgentSDK::AssistantMessage
112
- ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
113
- when ClaudeAgentSDK::ResultMessage
114
- ChatChannel.broadcast_to(chat_id, {
115
- type: 'complete',
116
- content: message.result,
117
- cost: message.total_cost_usd
118
- })
119
- end
155
+ ClaudeAgentSDK::Client.open(options: options) do |client|
156
+ client.query(message_content)
157
+
158
+ client.receive_response do |message|
159
+ case message
160
+ when ClaudeAgentSDK::AssistantMessage
161
+ ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
162
+ when ClaudeAgentSDK::ResultMessage
163
+ ChatChannel.broadcast_to(chat_id, {
164
+ type: 'complete',
165
+ content: message.result,
166
+ cost: message.total_cost_usd
167
+ })
120
168
  end
121
- ensure
122
- client.disconnect
123
169
  end
124
- end.wait
170
+ end
125
171
  end
126
172
  end
127
173
  ```
128
174
 
175
+ `Client.open` connects, yields the client, and always disconnects — also when the block raises, so the job's error handling sees the original exception. It runs inside an existing reactor or starts its own, so a job needs no `Async { }.wait` wrapper. Its return value is the block's; to leave the block early use `next`, not `break` (outside an `Async` block, `break` raises `LocalJumpError`, though the session is still torn down). `break` inside `receive_response` itself is fine.
176
+
129
177
  ## Session Resumption
130
178
 
131
179
  Persist Claude sessions for multi-turn conversations:
@@ -136,19 +184,13 @@ class ChatSession < ApplicationRecord
136
184
  # Columns: id, claude_session_id, user_id, created_at, updated_at
137
185
 
138
186
  def send_message(content)
139
- options = build_options
140
- client = ClaudeAgentSDK::Client.new(options: options)
141
-
142
- Async do
143
- client.connect
144
- client.query(content, session_id: claude_session_id ? nil : generate_session_id)
187
+ ClaudeAgentSDK::Client.open(options: build_options) do |client|
188
+ client.query(content)
145
189
 
146
190
  client.receive_response do |message|
147
191
  update!(claude_session_id: message.session_id) if message.is_a?(ClaudeAgentSDK::ResultMessage)
148
192
  end
149
- ensure
150
- client.disconnect
151
- end.wait
193
+ end
152
194
  end
153
195
 
154
196
  private
@@ -158,13 +200,11 @@ class ChatSession < ApplicationRecord
158
200
  opts[:resume] = claude_session_id if claude_session_id.present?
159
201
  ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
160
202
  end
161
-
162
- def generate_session_id
163
- "chat_#{id}_#{Time.current.to_i}"
164
- end
165
203
  end
166
204
  ```
167
205
 
206
+ The first message starts a new session; every later one resumes it by the ID the previous `ResultMessage` reported.
207
+
168
208
  ## Background Jobs with Error Handling
169
209
 
170
210
  ```ruby
@@ -174,17 +214,17 @@ class ClaudeAgentJob < ApplicationJob
174
214
 
175
215
  def perform(task_id)
176
216
  task = Task.find(task_id)
177
- Async { execute_agent(task) }.wait
217
+
218
+ ClaudeAgentSDK::Client.open(options: ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 10)) do |client|
219
+ client.query(task.prompt)
220
+ client.receive_response do |message|
221
+ task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
222
+ end
223
+ end
178
224
  rescue ClaudeAgentSDK::CLINotFoundError
179
- task.update!(status: 'failed', error: 'Claude CLI not installed')
225
+ task.update!(status: 'failed', error: 'Claude CLI not installed (bin/rails claude_agent_sdk:install_cli)')
180
226
  raise
181
227
  end
182
-
183
- private
184
-
185
- def execute_agent(task)
186
- # ... agent execution
187
- end
188
228
  end
189
229
  ```
190
230
 
@@ -248,7 +288,8 @@ ClaudeAgentSDK.configure do |config|
248
288
  # Use a lambda so each query gets a fresh observer instance (thread-safe).
249
289
  # A single shared instance would have its span state clobbered by concurrent requests.
250
290
  -> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }
251
- ] : []
291
+ ] : [],
292
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
252
293
  }
253
294
  end
254
295
  ```
data/docs/sessions.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. These APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees.
4
4
 
5
- Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution.
5
+ Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike.
6
6
 
7
7
  ## Listing Sessions
8
8
 
@@ -25,6 +25,8 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true)
25
25
 
26
26
  Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`.
27
27
 
28
+ Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths (a blank `cwd` falls back to the project path).
29
+
28
30
  ## Reading Session Messages
29
31
 
30
32
  ```ruby
@@ -125,7 +127,7 @@ ClaudeAgentSDK.fork_session(
125
127
  )
126
128
  ```
127
129
 
128
- > Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` uses `O_CREAT | O_EXCL` to prevent race conditions.
130
+ > Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched.
129
131
 
130
132
  ## Resuming at a Specific Message
131
133
 
@@ -210,18 +212,45 @@ ClaudeAgentSDK.query(
210
212
  ```
211
213
 
212
214
  Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
213
- `"eager"` to flush after every frame), and `load_timeout_ms` (per store call
214
- during resume materialization, default `60_000`).
215
-
216
- > **Store-backed resume runs against a bare temp `CLAUDE_CONFIG_DIR`.** Only the
217
- > transcript plus `.credentials.json` (redacted) and `.claude.json` are
218
- > materialized into it — user-scope `settings.json` (hooks, `permissions`),
219
- > user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` from your real config
220
- > dir are **not** visible to the subprocess, so a store-backed resume can
221
- > behave differently from a plain `resume:` of the same session. Project-level
222
- > `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
223
- > programmatically via `ClaudeAgentOptions` are unaffected. This matches the
224
- > Python and TypeScript SDKs.
215
+ `"eager"` to flush each frame as soon as the store is free — frames arriving
216
+ while an append is in flight are coalesced into the next append, so a slow
217
+ store never accumulates one background task per frame), and `load_timeout_ms`
218
+ (per store call during resume materialization, default `60_000`).
219
+
220
+ Resume materialization re-serializes each loaded entry to JSONL. An entry that
221
+ cannot be serialized (NaN/Infinity, invalid UTF-8, circular nesting), an
222
+ unserializable subagent metadata sidecar, or a subkey that is not a safe
223
+ relative String path is skipped with a warning on stderr (naming the entry's
224
+ `uuid` when it has one) rather than aborting the resume. A session left with
225
+ no usable entries is treated like an empty one: `resume:` falls through to the
226
+ normal spawn path and `continue_conversation` moves on to the next candidate.
227
+
228
+ > **Store-backed resume runs against a temp `CLAUDE_CONFIG_DIR`.** The SDK
229
+ > materializes the session transcript (plus subagent transcripts, when the
230
+ > store implements `#list_subkeys`) into it and seeds it from your real config
231
+ > dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude`):
232
+ >
233
+ > - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
234
+ > subprocess can't consume it. On macOS with the default config dir and no
235
+ > `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from
236
+ > the Keychain entry when one exists (the redirected config dir would
237
+ > otherwise miss it).
238
+ > - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else
239
+ > `~/.claude.json`).
240
+ > - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`,
241
+ > hooks and `permissions` still apply — minus `enabledPlugins`,
242
+ > `extraKnownMarketplaces` and `env.CLAUDE_CONFIG_DIR`, which would misbehave
243
+ > under the redirected config dir (plugin declarations would re-install every
244
+ > declared marketplace on each resume).
245
+ >
246
+ > Everything else in your config dir is **not** visible to the subprocess —
247
+ > notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the
248
+ > plugin keys stripped, user plugins are off) — so a store-backed resume can
249
+ > still behave differently from a plain `resume:` of the same session.
250
+ > Project-level `.claude/*` still applies (it resolves from `cwd`), and
251
+ > hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected.
252
+ > Seeded files are written owner-only (`0600`); a missing source file is simply
253
+ > skipped.
225
254
  >
226
255
  > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
227
256
  > (terminal append failures — timeouts immediately, other failures after up to
@@ -235,6 +264,13 @@ during resume materialization, default `60_000`).
235
264
  Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
236
265
  `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
237
266
  `#list_session_summaries` are optional and probed via `SessionStore.implements?`.
267
+ Report `mtime` as epoch milliseconds; the SDK also orders numeric-string and
268
+ ISO-8601-string mtimes correctly, but anything else sorts as oldest. Subagent
269
+ transcripts arrive under a `subpath` key such as `subagents/agent-<agent_id>`
270
+ (or nested `subagents/workflows/<runId>/agent-<agent_id>`); on a store without
271
+ `#list_subkeys` the subagent readers build `subagents/agent-<agent_id>` from the
272
+ caller's `agent_id`, which the SDK first restricts to `[A-Za-z0-9._-]+`
273
+ (never `.`/`..`), so a path- or prefix-keyed adapter cannot be re-routed by it.
238
274
  Validate your adapter with the shipped, framework-agnostic conformance harness:
239
275
 
240
276
  ```ruby
@@ -282,6 +318,16 @@ Declaring `:inline` means the calls run in place on the reactor fiber under a
282
318
  The drop is surfaced like every dropped batch — `MirrorErrorMessage` on
283
319
  the stream, `batches_dropped?` on the batcher — and the local transcript
284
320
  remains the source of truth, so nothing is lost from the session itself.
321
+ - The timeout bounds the cancellation **request**, not the call's
322
+ completion: the cancellation is delivered once, at the next suspension
323
+ point, and the adapter's `ensure` / rescue cleanup then runs unbounded on
324
+ the reactor fiber before the timeout is reported. Fiber-aware cleanup
325
+ (closing an async client, releasing an async lock) delays only that call;
326
+ scheduler-opaque cleanup — an `fsync`, a non-fiber-aware driver's
327
+ disconnect, a GVL-holding C extension — stalls the whole reactor for its
328
+ duration, and no deadline can interrupt it. Keep inline cleanup
329
+ fiber-aware, or leave the adapter on the default thread hop when a hard
330
+ bound on the whole call matters more than fiber affinity.
285
331
 
286
332
  Anything other than `:thread`/`:inline` raises `ArgumentError` when the
287
333
  session is set up; without a reactor the hard thread-hop bound still applies
@@ -308,7 +354,12 @@ The browsing/mutation helpers above have store-backed counterparts that take a
308
354
  project keys (parity with the Python SDK).
309
355
  - Mutations: `rename_session_via_store`, `tag_session_via_store`,
310
356
  `delete_session_via_store` (a no-op on append-only stores without `#delete`),
311
- `fork_session_via_store`.
357
+ `fork_session_via_store`. Like their disk counterparts, rename, tag, and fork
358
+ raise `Errno::ENOENT` for a session the store has never seen (`#load`
359
+ returns nil or `[]`) instead of appending to — and so creating — a phantom
360
+ session. Rename/tag probe with one `#load` before appending; the probe is
361
+ check-then-act, so a session deleted concurrently between the probe and the
362
+ append can still be recreated by that append.
312
363
  - Migration: `import_session_to_store` replays a local on-disk session (and its
313
364
  subagents) into a store.
314
365
 
@@ -26,7 +26,9 @@ module ClaudeAgentSDK
26
26
  # Stdlib only (net/http, json, digest, fileutils, rbconfig) — the gem gains
27
27
  # no runtime dependency for this.
28
28
  #
29
- # @example Pin a version in bin/setup or a Dockerfile build step
29
+ # @example Install the gem's tested version in bin/setup or a Dockerfile build step
30
+ # ClaudeAgentSDK::CLIInstaller.install_pinned
31
+ # @example Pin a version of your own
30
32
  # ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
31
33
  module CLIInstaller
32
34
  BASE_URL = 'https://downloads.claude.ai/claude-code-releases'
@@ -39,6 +41,13 @@ module ClaudeAgentSDK
39
41
  # path — silently installing something other than the pinned version.
40
42
  VERSION_PATTERN = /\A\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\z/
41
43
  CHECKSUM_PATTERN = /\A[0-9a-f]{64}\z/
44
+ # The CLI version this gem release is developed and tested against — the
45
+ # Ruby equivalent of the Python SDK's bundled-CLI pin (_cli_version.py),
46
+ # except nothing is shipped inside the gem; +install_pinned+ downloads it.
47
+ # Single source of truth: bumped here (and only here) by
48
+ # .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
49
+ # Dependabot bump of the gem carries the CLI forward with it.
50
+ PINNED_CLI_VERSION = '2.1.280'
42
51
  BINARY_NAME = 'claude'
43
52
  VERSION_FILE = 'VERSION'
44
53
  LOCK_FILE = '.install.lock'
@@ -263,34 +272,33 @@ module ClaudeAgentSDK
263
272
  end
264
273
  end
265
274
 
266
- # The VERSION file: line 1 the installed version, line 2 the SHA-256 of the
267
- # binary that was verified at install time. The checksum is what lets the
268
- # idempotency shortcut trust the vendored binary without a network call —
269
- # a truncated, swapped or half-written binary no longer looks installed.
270
- # An older single-line VERSION file simply reads as "no metadata", which
271
- # triggers a clean reinstall.
275
+ # The VERSION file: version, verified SHA-256, and target platform, one
276
+ # per line. Both platform and checksum must match before trusting a cached
277
+ # binary offline: a cache copied between OS/CPU/libc targets is not usable.
278
+ # Older one- or two-line files lack that proof and trigger a clean reinstall.
272
279
  module Metadata
273
280
  class << self
274
281
  def read(dir)
275
282
  path = File.join(dir, VERSION_FILE)
276
283
  return nil unless File.file?(path)
277
284
 
278
- version, checksum = File.read(path, METADATA_READ_LIMIT).to_s.split("\n", 3)
285
+ version, checksum, platform = File.read(path, METADATA_READ_LIMIT).to_s.split("\n", 4)
279
286
  version = version.to_s.strip
280
287
  checksum = checksum.to_s.strip.downcase
281
- return nil unless version.match?(VERSION_PATTERN) && checksum.match?(CHECKSUM_PATTERN)
288
+ platform = platform.to_s.strip
289
+ return nil unless version.match?(VERSION_PATTERN) && checksum.match?(CHECKSUM_PATTERN) && !platform.empty?
282
290
 
283
- { version: version, checksum: checksum }
291
+ { version: version, checksum: checksum, platform: platform }
284
292
  end
285
293
 
286
294
  # Atomic: an unpredictable temp name opened O_EXCL, then renamed over
287
295
  # the old file. Without this a reader could observe a half-written
288
296
  # VERSION, or (worse) the previous version paired with a new binary.
289
- def write(dir, version, checksum)
297
+ def write(dir, version, checksum, platform)
290
298
  tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp")
291
299
  begin
292
300
  File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
293
- file.write("#{version}\n#{checksum}\n")
301
+ file.write("#{version}\n#{checksum}\n#{platform}\n")
294
302
  end
295
303
  File.rename(tmp, File.join(dir, VERSION_FILE))
296
304
  ensure
@@ -334,9 +342,9 @@ module ClaudeAgentSDK
334
342
  with_install_lock(dir) do
335
343
  sweep_stale_temp_files(dir)
336
344
  resolved = Release.resolve_version(requested)
337
- next binary if installed?(dir, resolved)
338
-
339
345
  platform = Platform.detect
346
+ next binary if installed?(dir, resolved, platform)
347
+
340
348
  publish(dir, binary, resolved, platform, Release.platform_entry(resolved, platform))
341
349
  binary
342
350
  end
@@ -349,6 +357,14 @@ module ClaudeAgentSDK
349
357
  raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}"
350
358
  end
351
359
 
360
+ # Install PINNED_CLI_VERSION — the version this gem release was tested
361
+ # against. The Dockerfile / bin/setup form of "pin the tested pair":
362
+ # bumping the gem moves the CLI with it, with no version literal in the
363
+ # caller to keep in sync.
364
+ def install_pinned(dir: nil)
365
+ install(version: PINNED_CLI_VERSION, dir: dir)
366
+ end
367
+
352
368
  # Path of an already-installed binary, or nil.
353
369
  #
354
370
  # Deliberately lock-free, because #publish makes the lock unnecessary
@@ -400,13 +416,13 @@ module ClaudeAgentSDK
400
416
  end
401
417
 
402
418
  # True only when the vendored binary is byte-for-byte the one recorded
403
- # by a previous install of this exact version. No network access.
404
- def installed?(dir, version)
419
+ # by a previous install of this exact version and platform. No network access.
420
+ def installed?(dir, version, platform)
405
421
  binary = installed_path(dir: dir)
406
422
  return false unless binary
407
423
 
408
424
  recorded = Metadata.read(dir)
409
- return false unless recorded && recorded[:version] == version
425
+ return false unless recorded && recorded[:version] == version && recorded[:platform] == platform
410
426
 
411
427
  Digest::SHA256.file(binary).hexdigest == recorded[:checksum]
412
428
  rescue SystemCallError
@@ -434,7 +450,7 @@ module ClaudeAgentSDK
434
450
  tmp = "#{binary}.download.#{SecureRandom.hex(8)}"
435
451
  begin
436
452
  fetch_verified(version, platform, entry, tmp)
437
- Metadata.write(dir, version, entry[:checksum])
453
+ Metadata.write(dir, version, entry[:checksum], platform)
438
454
  File.rename(tmp, binary)
439
455
  ensure
440
456
  FileUtils.rm_f(tmp)
@@ -373,7 +373,7 @@ module ClaudeAgentSDK
373
373
  end
374
374
 
375
375
  # `--thinking-display` toggles between `"summarized"` (visible thinking
376
- # text) and `"omitted"` (empty thinking, signature only). Opus 4.7 defaults
376
+ # text) and `"omitted"` (empty thinking, signature only). Current models default
377
377
  # to `"omitted"`, so pass `display: "summarized"` to see reasoning.
378
378
  def append_thinking_display(cmd, display)
379
379
  return if display.nil?
@@ -444,8 +444,12 @@ module ClaudeAgentSDK
444
444
  # Typed Mcp*ServerConfig objects serialize via their wire hash —
445
445
  # without this they'd JSON-stringify as "#<...>" via to_s.
446
446
  config = config.to_h if config.is_a?(Type)
447
- servers_for_cli[name] = if config.is_a?(Hash) && config[:type] == "sdk"
448
- config.except(:instance)
447
+ # Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers:
448
+ # either key style (and a Symbol :sdk type). The live instance is
449
+ # never serialized — JSON.generate would raise on it or leak its
450
+ # #to_s onto the command line.
451
+ servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config["type"]).to_s == "sdk"
452
+ config.except(:instance, "instance")
449
453
  else
450
454
  config
451
455
  end
@@ -506,6 +510,10 @@ module ClaudeAgentSDK
506
510
  end
507
511
 
508
512
  def load_settings_file(path)
513
+ # Match the CLI's path resolution when --settings is passed through
514
+ # without a sandbox merge: the subprocess runs in options.cwd.
515
+ # Do not expand lexically: symlink/.. must resolve through the filesystem.
516
+ path = File.join(@options.cwd&.to_s || Dir.pwd, path) unless File.absolute_path?(path)
509
517
  # Missing file: warn and continue with sandbox-only settings (Python
510
518
  # parity: logger.warning("Settings file not found: ...") and an empty
511
519
  # settings object). Raising here turned a misconfiguration the CLI
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # default_options= copies through Type.deep_dup_for_options: keep this file
4
+ # loadable on its own (require 'claude_agent_sdk/configuration').
5
+ require_relative 'types'
6
+
3
7
  module ClaudeAgentSDK
4
8
  # Configuration class for setting default options
5
9
  #
@@ -28,11 +32,59 @@ module ClaudeAgentSDK
28
32
  # prompt: "Hello!",
29
33
  # options: ClaudeAgentOptions.new(model: 'opus') # overrides default
30
34
  # )
35
+ #
36
+ # Assignment stores a frozen deep copy of the Hash (containers, typed
37
+ # option values such as SandboxSettings, and mutable Strings are copied;
38
+ # procs, observers, SDK MCP server instances and store adapters keep
39
+ # identity). To change the defaults, assign a new Hash — in-place mutation
40
+ # of the stored one (including `<<` on one of its Strings) raises
41
+ # FrozenError, and later changes to the Hash or Strings you passed in have
42
+ # no effect.
31
43
  class Configuration
32
- attr_accessor :default_options
44
+ # The configured defaults: a frozen snapshot (see class docs).
45
+ #
46
+ # @return [Hash]
47
+ attr_reader :default_options
48
+
49
+ EMPTY_DEFAULTS = {}.freeze
50
+ private_constant :EMPTY_DEFAULTS
33
51
 
34
52
  def initialize
35
- @default_options = {}
53
+ @default_options = EMPTY_DEFAULTS
54
+ end
55
+
56
+ # The defaults are read at request time by every ClaudeAgentOptions.new
57
+ # (merge_with_defaults) with no lock, possibly from many threads at once.
58
+ # A live, caller-owned Hash made that a race: an in-place write from one
59
+ # thread while another iterated the merge raised "can't add a new key
60
+ # into hash during iteration" or tore the read. Storing a private frozen
61
+ # snapshot removes the shared mutable state instead of guarding it — the
62
+ # ivar swap is atomic, a reader only ever sees a complete Hash, and an
63
+ # in-place write fails loudly. Only the copy is frozen: the caller's
64
+ # Hash and objects, and identity leaves, are left untouched.
65
+ #
66
+ # @param value [Hash, nil] nil clears the defaults
67
+ def default_options=(value)
68
+ @default_options = value.nil? ? EMPTY_DEFAULTS : deep_freeze(Type.deep_dup_for_options(value))
69
+ end
70
+
71
+ private
72
+
73
+ # Mirrors Type.deep_dup_for_options' recursion: freeze the containers,
74
+ # option value copies and String copies it produced (and their nested
75
+ # state), never a leaf it returned by identity — freezing an
76
+ # SdkMcpServer or a store adapter would break it. A String reaching here
77
+ # is either the copier's own copy or was already frozen, so freezing it
78
+ # never touches a caller's mutable String.
79
+ def deep_freeze(value)
80
+ case value
81
+ when Hash then value.each_value { |v| deep_freeze(v) }
82
+ when Array then value.each { |v| deep_freeze(v) }
83
+ when Type::OptionValue then value.instance_variables.each { |ivar| deep_freeze(value.instance_variable_get(ivar)) }
84
+ when String then nil # nothing nested; fall through to freeze the copy
85
+ else return value
86
+ end
87
+ value.freeze
36
88
  end
37
89
  end
38
90
 
@@ -129,6 +129,15 @@ module ClaudeAgentSDK
129
129
 
130
130
  data.key?(key) ? data[key] : data[key.to_s]
131
131
  end
132
+
133
+ # The +api_error_status+ field narrowed to Integer-or-nil — the one
134
+ # narrowing both #api_error_status and .error_text read, so a "500"
135
+ # String can neither show up in the message nor go missing from the
136
+ # accessor on its own.
137
+ def api_error_status(data)
138
+ status = field(data, :api_error_status)
139
+ status.is_a?(Integer) ? status : nil
140
+ end
132
141
  end
133
142
  private_constant :Payload
134
143
 
@@ -158,7 +167,7 @@ module ClaudeAgentSDK
158
167
  subtype = Payload.field(data, :subtype)
159
168
  return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
160
169
 
161
- status = Payload.field(data, :api_error_status)
170
+ status = Payload.api_error_status(data)
162
171
  return "API error (HTTP #{status})" unless status.nil?
163
172
 
164
173
  'unknown error'
@@ -174,8 +183,7 @@ module ClaudeAgentSDK
174
183
  @errors = Payload.normalize_errors(Payload.field(data, :errors))
175
184
  result = Payload.field(data, :result)
176
185
  @result = result.is_a?(String) ? result : nil
177
- status = Payload.field(data, :api_error_status)
178
- @api_error_status = status.is_a?(Integer) ? status : nil
186
+ @api_error_status = Payload.api_error_status(data)
179
187
  reason = Payload.field(data, :terminal_reason)
180
188
  @terminal_reason = reason.is_a?(String) ? reason : nil
181
189
  session_id = Payload.field(data, :session_id)