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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +54 -19
- data/docs/cli-installer.md +40 -9
- data/docs/client.md +27 -18
- data/docs/configuration.md +5 -5
- data/docs/errors.md +4 -3
- data/docs/mcp-servers.md +22 -0
- data/docs/observability.md +6 -0
- data/docs/rails.md +92 -51
- data/docs/sessions.md +66 -15
- data/lib/claude_agent_sdk/cli_installer.rb +34 -18
- data/lib/claude_agent_sdk/command_builder.rb +11 -3
- data/lib/claude_agent_sdk/configuration.rb +54 -2
- data/lib/claude_agent_sdk/errors.rb +11 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
- data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
- data/lib/claude_agent_sdk/query.rb +140 -59
- data/lib/claude_agent_sdk/railtie.rb +94 -0
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +46 -4
- data/lib/claude_agent_sdk/session_mutations.rb +39 -12
- data/lib/claude_agent_sdk/session_resume.rb +112 -39
- data/lib/claude_agent_sdk/session_store.rb +19 -3
- data/lib/claude_agent_sdk/session_summary.rb +5 -5
- data/lib/claude_agent_sdk/sessions.rb +123 -55
- data/lib/claude_agent_sdk/streaming.rb +0 -8
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +30 -0
- data/lib/claude_agent_sdk/tasks.rb +13 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
- data/lib/claude_agent_sdk/types.rb +349 -39
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +53 -44
- data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
- metadata +29 -13
data/docs/rails.md
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
# Rails Integration
|
|
2
2
|
|
|
3
|
-
The SDK
|
|
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:
|
|
74
|
+
callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
|
|
32
75
|
}
|
|
33
76
|
end
|
|
34
77
|
```
|
|
35
78
|
|
|
36
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
150
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
151
|
+
system_prompt: { type: 'preset', preset: 'claude_code' },
|
|
152
|
+
permission_mode: 'bypassPermissions'
|
|
153
|
+
)
|
|
102
154
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
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",
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|