claude-agent-sdk 0.34.0 → 0.36.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +68 -24
  4. data/docs/cli-installer.md +38 -1
  5. data/docs/client.md +44 -20
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +22 -0
  8. data/docs/mcp-servers.md +37 -7
  9. data/docs/rails.md +92 -54
  10. data/docs/sessions.md +69 -33
  11. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  12. data/lib/claude_agent_sdk/cli_installer.rb +38 -8
  13. data/lib/claude_agent_sdk/deprecation.rb +51 -0
  14. data/lib/claude_agent_sdk/errors.rb +8 -0
  15. data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
  16. data/lib/claude_agent_sdk/option_warnings.rb +0 -2
  17. data/lib/claude_agent_sdk/query.rb +49 -8
  18. data/lib/claude_agent_sdk/railtie.rb +105 -0
  19. data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
  20. data/lib/claude_agent_sdk/session_mutations.rb +10 -10
  21. data/lib/claude_agent_sdk/session_resume.rb +19 -24
  22. data/lib/claude_agent_sdk/session_store.rb +28 -18
  23. data/lib/claude_agent_sdk/session_summary.rb +8 -3
  24. data/lib/claude_agent_sdk/sessions.rb +104 -18
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
  26. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
  27. data/lib/claude_agent_sdk/tasks.rb +13 -0
  28. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
  29. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
  30. data/lib/claude_agent_sdk/types.rb +219 -3
  31. data/lib/claude_agent_sdk/version.rb +1 -1
  32. data/lib/claude_agent_sdk.rb +261 -56
  33. data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
  34. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
  35. metadata +17 -6
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`. That holds whatever the process's working directory is (a daemonized worker, a job runner started elsewhere): the Railtie points `ClaudeAgentSDK::CLIInstaller.root` at `Rails.root` during boot, before `config/initializers` run, so an initializer can still set a different root, and a root already set in `config/application.rb` is kept. 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`. For the same reason the task never sees a root set in `config/initializers`; if you move the CLI elsewhere, set `CLIInstaller.root` in `config/application.rb`, which both the task and discovery honour. 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
 
@@ -10,8 +53,7 @@ You do **not** need to think about this. By default (`callback_scheduling: :thre
10
53
 
11
54
  ```ruby
12
55
  tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
13
- user = User.find(args[:id]) # just works
14
- { content: [{ type: 'text', text: user.name }] }
56
+ User.find(args[:id]).name # just works
15
57
  end
16
58
 
17
59
  ClaudeAgentSDK.query(prompt: '...') do |message|
@@ -23,17 +65,25 @@ The trade-off: because callbacks run on a plain thread rather than inside an `As
23
65
 
24
66
  ### Rails executor around callbacks: `callback_wrapper`
25
67
 
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:
68
+ 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
69
 
28
70
  ```ruby
29
71
  ClaudeAgentSDK.configure do |config|
30
72
  config.default_options = {
31
- callback_wrapper: ->(invocation) { Rails.application.executor.wrap { invocation.call } }
73
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
32
74
  }
33
75
  end
34
76
  ```
35
77
 
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.
78
+ 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:
79
+
80
+ - **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.
81
+ - **`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.
82
+ - **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor.
83
+
84
+ 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.
85
+
86
+ 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
87
 
38
88
  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
89
 
@@ -76,7 +126,7 @@ The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-b
76
126
  ```ruby
77
127
  tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
78
128
  row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
79
- { content: [{ type: 'text', text: row.to_json }] }
129
+ row.to_json
80
130
  end
81
131
  ```
82
132
 
@@ -96,38 +146,33 @@ class ChatAgentJob < ApplicationJob
96
146
  queue_as :claude_agents
97
147
 
98
148
  def perform(chat_id, message_content)
99
- Async do
100
- options = ClaudeAgentSDK::ClaudeAgentOptions.new(
101
- system_prompt: { type: 'preset', preset: 'claude_code' },
102
- permission_mode: 'bypassPermissions'
103
- )
149
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
150
+ system_prompt: { type: 'preset', preset: 'claude_code' },
151
+ permission_mode: 'bypassPermissions'
152
+ )
104
153
 
105
- client = ClaudeAgentSDK::Client.new(options: options)
106
-
107
- begin
108
- client.connect
109
- client.query(message_content)
110
-
111
- client.receive_response do |message|
112
- case message
113
- when ClaudeAgentSDK::AssistantMessage
114
- ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
115
- when ClaudeAgentSDK::ResultMessage
116
- ChatChannel.broadcast_to(chat_id, {
117
- type: 'complete',
118
- content: message.result,
119
- cost: message.total_cost_usd
120
- })
121
- end
154
+ ClaudeAgentSDK::Client.open(options: options) do |client|
155
+ client.query(message_content)
156
+
157
+ client.receive_response do |message|
158
+ case message
159
+ when ClaudeAgentSDK::AssistantMessage
160
+ ChatChannel.broadcast_to(chat_id, { type: 'chunk', content: message.text })
161
+ when ClaudeAgentSDK::ResultMessage
162
+ ChatChannel.broadcast_to(chat_id, {
163
+ type: 'complete',
164
+ content: message.result,
165
+ cost: message.total_cost_usd
166
+ })
122
167
  end
123
- ensure
124
- client.disconnect
125
168
  end
126
- end.wait
169
+ end
127
170
  end
128
171
  end
129
172
  ```
130
173
 
174
+ `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.
175
+
131
176
  ## Session Resumption
132
177
 
133
178
  Persist Claude sessions for multi-turn conversations:
@@ -138,19 +183,13 @@ class ChatSession < ApplicationRecord
138
183
  # Columns: id, claude_session_id, user_id, created_at, updated_at
139
184
 
140
185
  def send_message(content)
141
- options = build_options
142
- client = ClaudeAgentSDK::Client.new(options: options)
143
-
144
- Async do
145
- client.connect
146
- client.query(content, session_id: claude_session_id ? nil : generate_session_id)
186
+ ClaudeAgentSDK::Client.open(options: build_options) do |client|
187
+ client.query(content)
147
188
 
148
189
  client.receive_response do |message|
149
190
  update!(claude_session_id: message.session_id) if message.is_a?(ClaudeAgentSDK::ResultMessage)
150
191
  end
151
- ensure
152
- client.disconnect
153
- end.wait
192
+ end
154
193
  end
155
194
 
156
195
  private
@@ -160,13 +199,11 @@ class ChatSession < ApplicationRecord
160
199
  opts[:resume] = claude_session_id if claude_session_id.present?
161
200
  ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
162
201
  end
163
-
164
- def generate_session_id
165
- "chat_#{id}_#{Time.current.to_i}"
166
- end
167
202
  end
168
203
  ```
169
204
 
205
+ The first message starts a new session; every later one resumes it by the ID the previous `ResultMessage` reported.
206
+
170
207
  ## Background Jobs with Error Handling
171
208
 
172
209
  ```ruby
@@ -176,17 +213,17 @@ class ClaudeAgentJob < ApplicationJob
176
213
 
177
214
  def perform(task_id)
178
215
  task = Task.find(task_id)
179
- Async { execute_agent(task) }.wait
216
+
217
+ ClaudeAgentSDK::Client.open(options: ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 10)) do |client|
218
+ client.query(task.prompt)
219
+ client.receive_response do |message|
220
+ task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
221
+ end
222
+ end
180
223
  rescue ClaudeAgentSDK::CLINotFoundError
181
- task.update!(status: 'failed', error: 'Claude CLI not installed')
224
+ task.update!(status: 'failed', error: 'Claude CLI not installed (bin/rails claude_agent_sdk:install_cli)')
182
225
  raise
183
226
  end
184
-
185
- private
186
-
187
- def execute_agent(task)
188
- # ... agent execution
189
- end
190
227
  end
191
228
  ```
192
229
 
@@ -250,7 +287,8 @@ ClaudeAgentSDK.configure do |config|
250
287
  # Use a lambda so each query gets a fresh observer instance (thread-safe).
251
288
  # A single shared instance would have its span state clobbered by concurrent requests.
252
289
  -> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }
253
- ] : []
290
+ ] : [],
291
+ callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
254
292
  }
255
293
  end
256
294
  ```
data/docs/sessions.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Session Browsing & Mutations
2
2
 
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.
3
+ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. By default 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. On a host with no usable home directory (`HOME` unset with no passwd entry — e.g. `docker --user` in a minimal image — or an empty/relative `HOME`) and no `CLAUDE_CONFIG_DIR`, the local-disk path raises `ClaudeAgentSDK::ConfigDirError`; set `CLAUDE_CONFIG_DIR` there. Every one of them also takes an optional `session_store:` to operate on a [`SessionStore`](#mirroring-to-a-sessionstore) instead (see [Store-backed sessions](#store-backed-sessions)).
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. 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.
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. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String.
6
6
 
7
7
  ## Listing Sessions
8
8
 
@@ -25,7 +25,9 @@ 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).
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. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths.
29
+
30
+ When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (worktree listings: the worktree `git worktree list` reports first, i.e. the main worktree).
29
31
 
30
32
  ## Reading Session Messages
31
33
 
@@ -49,7 +51,7 @@ ids = ClaudeAgentSDK.list_subagents(session_id: "uuid-here", directory: "/path/t
49
51
  messages = ClaudeAgentSDK.get_subagent_messages(session_id: "uuid-here", agent_id: ids.first, limit: 50)
50
52
  ```
51
53
 
52
- With `directory:` given, only that project and its git worktrees are searched (no global fallback). Store-backed counterparts: `list_subagents_from_store` / `get_subagent_messages_from_store`.
54
+ With `directory:` given, only that project and its git worktrees are searched (no global fallback). Pass `session_store:` to read the subagents mirrored into a store instead.
53
55
 
54
56
  > Each returned `SessionMessage` carries `parent_tool_use_id` — the id of the Agent `tool_use` block in the parent session that spawned this subagent — and `parent_agent_id`, the spawning subagent's id for nested subagents. Both are read from the `agent-<id>.meta.json` sidecar beside the transcript (or the `agent_metadata` entry in a `SessionStore`), and are `nil` when it is missing or unusable.
55
57
 
@@ -57,8 +59,8 @@ With `directory:` given, only that project and its git worktrees are searched (n
57
59
 
58
60
  ```ruby
59
61
  meta = ClaudeAgentSDK.get_subagent_metadata(session_id: session_id, agent_id: agent_id, directory: project)
60
- meta = ClaudeAgentSDK.get_subagent_metadata_from_store(
61
- session_store: store, session_id: session_id, agent_id: agent_id, directory: project
62
+ meta = ClaudeAgentSDK.get_subagent_metadata(
63
+ session_id: session_id, agent_id: agent_id, directory: project, session_store: store
62
64
  )
63
65
  meta&.dig('toolUseId') # spawning Agent tool call; not task_id
64
66
  meta&.dig('parentAgentId')
@@ -211,6 +213,14 @@ ClaudeAgentSDK.query(
211
213
  ) { |message| }
212
214
  ```
213
215
 
216
+ The mirror maps each transcript file the CLI reports to a store key relative to
217
+ the subprocess's projects dir: `CLAUDE_CONFIG_DIR` from `options.env` (else
218
+ `ENV`), else `~/.claude` under the `HOME` the subprocess sees (`options.env`'s
219
+ `HOME` when it sets one). When neither exists — no `CLAUDE_CONFIG_DIR` and no
220
+ usable home — the session still runs, but nothing is mirrored: each unmappable
221
+ batch is reported as a `MirrorErrorMessage` (with a `nil` key) telling you to
222
+ set `CLAUDE_CONFIG_DIR`.
223
+
214
224
  Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
215
225
  `"eager"` to flush each frame as soon as the store is free — frames arriving
216
226
  while an append is in flight are coalesced into the next append, so a slow
@@ -228,7 +238,8 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
228
238
  > **Store-backed resume runs against a temp `CLAUDE_CONFIG_DIR`.** The SDK
229
239
  > materializes the session transcript (plus subagent transcripts, when the
230
240
  > store implements `#list_subkeys`) into it and seeds it from your real config
231
- > dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude`):
241
+ > dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude` under the
242
+ > `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one):
232
243
  >
233
244
  > - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
234
245
  > subprocess can't consume it. On macOS with the default config dir and no
@@ -264,8 +275,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
264
275
  Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
265
276
  `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
266
277
  `#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
278
+ Report `mtime` as epoch milliseconds; the SDK also accepts numeric-string,
279
+ ISO-8601-string and `Time` mtimes (ordering them correctly and reporting them
280
+ as Integer epoch ms in `last_modified`), but anything else sorts as oldest and
281
+ reads as `0`. `continue_conversation` picks the newest candidate by the same
282
+ rule as the listings (equal mtimes: lowest `session_id`). Subagent
269
283
  transcripts arrive under a `subpath` key such as `subagents/agent-<agent_id>`
270
284
  (or nested `subagents/workflows/<runId>/agent-<agent_id>`); on a store without
271
285
  `#list_subkeys` the subagent readers build `subagents/agent-<agent_id>` from the
@@ -340,30 +354,52 @@ thread for default adapters, inside the cooperative timeout for inline
340
354
  declarers (the cancellation passes through the wrapper un-swallowed and the
341
355
  wrapper's `ensure` runs at cancellation).
342
356
 
343
- ### Store-backed helpers
344
-
345
- The browsing/mutation helpers above have store-backed counterparts that take a
346
- `session_store:` and operate on the store instead of local disk:
347
-
348
- - Reads: `list_sessions_from_store`, `get_session_info_from_store`,
349
- `get_session_messages_from_store`, `list_subagents_from_store`,
350
- `get_subagent_messages_from_store`. Unlike the disk readers (where a nil
351
- `directory:` searches every project directory), the store helpers key every
352
- read by `project_key` and a nil `directory:` defaults to the **current
353
- working directory** — the `SessionStore` interface has no way to enumerate
354
- project keys (parity with the Python SDK).
355
- - Mutations: `rename_session_via_store`, `tag_session_via_store`,
356
- `delete_session_via_store` (a no-op on append-only stores without `#delete`),
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.
363
- - Migration: `import_session_to_store` replays a local on-disk session (and its
364
- subagents) into a store.
357
+ ### Store-backed sessions
358
+
359
+ Every browsing/mutation function above takes an optional `session_store:`.
360
+ Omitted (or `nil`), it works on local disk as described above; given a store,
361
+ it operates on the store instead, with the same arguments:
365
362
 
366
363
  ```ruby
367
- ClaudeAgentSDK.rename_session_via_store(session_store: store, session_id: '550e8400-...', title: 'Renamed')
368
- forked = ClaudeAgentSDK.fork_session_via_store(session_store: store, session_id: '550e8400-...')
364
+ ClaudeAgentSDK.list_sessions(session_store: store, limit: 10)
365
+ ClaudeAgentSDK.get_session_messages(session_id: '550e8400-...', session_store: store)
366
+ ClaudeAgentSDK.rename_session(session_id: '550e8400-...', title: 'Renamed', session_store: store)
367
+ forked = ClaudeAgentSDK.fork_session(session_id: '550e8400-...', session_store: store)
369
368
  ```
369
+
370
+ Where the store path differs from the disk path:
371
+
372
+ - **`directory: nil` means the current working directory.** The disk readers
373
+ search every project directory when `directory:` is nil; a store keys every
374
+ read and write by `project_key` and has no way to enumerate project keys
375
+ (parity with the Python SDK).
376
+ - **`include_worktrees:` is disk-only.** A store has no worktrees, so with
377
+ `session_store:` only the default `true` is accepted; `false` or `nil`
378
+ raises `ArgumentError` rather than being silently ignored.
379
+ - `list_sessions` uses the store's `#list_session_summaries` when implemented,
380
+ else `#list_sessions` plus one `#load` per listed session; a store with
381
+ neither raises `ArgumentError`. `list_subagents` requires `#list_subkeys`.
382
+ - Rename, tag, and fork raise `Errno::ENOENT` for a session the store has
383
+ never seen (`#load` returns nil or `[]`) instead of appending to — and so
384
+ creating — a phantom session, like their disk counterparts. Rename/tag probe
385
+ with one `#load` before appending; the probe is check-then-act, so a session
386
+ deleted concurrently between the probe and the append can still be
387
+ recreated by that append. The appended entries carry a fresh `uuid` and
388
+ `timestamp`, so adapters that dedupe by `uuid` treat them correctly.
389
+ - `delete_session` is a no-op on append-only stores without `#delete` (the
390
+ disk path raises `Errno::ENOENT` for an unknown session); whether subagent
391
+ entries are removed too depends on the store's delete cascade.
392
+
393
+ To migrate, `import_session_to_store` replays a local on-disk session (and its
394
+ subagents) into a store.
395
+
396
+ > **Deprecated:** the separate store functions (`list_sessions_from_store`,
397
+ > `get_session_info_from_store`, `get_session_messages_from_store`,
398
+ > `list_subagents_from_store`, `get_subagent_metadata_from_store`,
399
+ > `get_subagent_messages_from_store`, `rename_session_via_store`,
400
+ > `tag_session_via_store`, `delete_session_via_store`,
401
+ > `fork_session_via_store`) still work unchanged but print a one-time
402
+ > deprecation warning and will be removed in 1.0. Replace
403
+ > `ClaudeAgentSDK.x_from_store(session_store: store, ...)` or
404
+ > `x_via_store(session_store: store, ...)` with
405
+ > `ClaudeAgentSDK.x(..., session_store: store)`.
@@ -20,7 +20,8 @@ module ClaudeAgentSDK
20
20
  cancelled?
21
21
  end
22
22
 
23
- # @api private Called by the SDK when the request is no longer actionable.
23
+ # Called by the SDK when the request is no longer actionable.
24
+ # @api private
24
25
  def cancel
25
26
  @queue.close
26
27
  end
@@ -51,9 +51,10 @@ module ClaudeAgentSDK
51
51
  BINARY_NAME = 'claude'
52
52
  VERSION_FILE = 'VERSION'
53
53
  LOCK_FILE = '.install.lock'
54
- # Relative to Dir.pwd, resolved at CALL time by .default_dir — an absolute
55
- # constant would freeze the working directory as of require time, which is
56
- # wrong for anything that chdirs (Rake tasks, bin/setup, test suites).
54
+ # Relative to .root (Dir.pwd when unset), resolved at CALL time by
55
+ # .default_dir — an absolute constant would freeze the working directory
56
+ # as of require time, which is wrong for anything that chdirs (Rake
57
+ # tasks, bin/setup, test suites).
57
58
  DEFAULT_DIR = File.join('vendor', 'claude')
58
59
  # Response caps. The dist-tag endpoints return a bare version string and
59
60
  # manifests are a few KB; anything larger is a misrouted response, not
@@ -191,13 +192,13 @@ module ClaudeAgentSDK
191
192
  raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}"
192
193
  end
193
194
 
194
- def follow_redirect(uri, response, redirects_left, &block)
195
+ def follow_redirect(uri, response, redirects_left, &)
195
196
  raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0
196
197
 
197
198
  location = response['location'].to_s
198
199
  raise CLIInstallError, "Redirect from #{uri} is missing a Location header" if location.empty?
199
200
 
200
- with_response(URI.join(uri.to_s, location), redirects_left - 1, &block)
201
+ with_response(URI.join(uri.to_s, location), redirects_left - 1, &)
201
202
  end
202
203
  end
203
204
  end
@@ -309,10 +310,39 @@ module ClaudeAgentSDK
309
310
  end
310
311
 
311
312
  class << self
312
- # Absolute path of the default install directory, resolved against the
313
- # current working directory each time it is asked for.
313
+ # The directory DEFAULT_DIR is resolved against, or nil (the default)
314
+ # for the current working directory at call time.
315
+ #
316
+ # Set it when the process cwd is not the project root — a daemonized
317
+ # worker, a job runner started from /, a systemd unit without
318
+ # WorkingDirectory — so .default_dir, and with it .installed_path and
319
+ # SubprocessCLITransport's discovery of the vendored binary, still
320
+ # point at <root>/vendor/claude. The Rails Railtie sets it to
321
+ # Rails.root unless something already has.
322
+ #
323
+ # Safe to read from any thread without a lock: the value is a single
324
+ # frozen String reference (or nil), replaced whole by .root=, so a
325
+ # reader sees either the old root or the new one, never a partial one.
326
+ #
327
+ # @return [String, nil] an absolute path, or nil
328
+ attr_reader :root
329
+
330
+ # @param path [String, Pathname, nil] the project root. A relative path
331
+ # is absolutized against the working directory NOW, once, so a later
332
+ # chdir cannot move it. nil restores the Dir.pwd default.
333
+ # @raise [ArgumentError] for an empty path (which would silently pin
334
+ # the current working directory)
335
+ def root=(path)
336
+ raise ArgumentError, 'CLIInstaller.root must be a non-empty path or nil' if path&.to_s&.empty?
337
+
338
+ @root = path && File.expand_path(path).freeze
339
+ end
340
+
341
+ # Absolute path of the default install directory: vendor/claude under
342
+ # .root, or under the current working directory (resolved each time it
343
+ # is asked for) while .root is unset.
314
344
  def default_dir
315
- File.expand_path(DEFAULT_DIR, Dir.pwd)
345
+ File.expand_path(DEFAULT_DIR, root || Dir.pwd)
316
346
  end
317
347
 
318
348
  # Install the CLI into +dir+ and return the absolute path of the binary.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # One-time deprecation warnings for public API slated for removal in the
5
+ # next major release (see the deprecation policy in issue #126).
6
+ #
7
+ # Emitted with plain Kernel#warn, deliberately NOT `category: :deprecated`:
8
+ # Ruby hides that category unless Warning[:deprecated] is enabled (off by
9
+ # default since 2.7.2, still off on 3.2-3.4), so a category-tagged warning
10
+ # would reach almost nobody before the removal. Plain warn is visible by
11
+ # default and still silenced by `-W0` / `$VERBOSE = nil`.
12
+ #
13
+ # @api private
14
+ module Deprecation
15
+ @warned = Set.new
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Warn once per process that ClaudeAgentSDK.+name+ is deprecated.
20
+ #
21
+ # Must be called directly from the deprecated method: `uplevel: 2`
22
+ # skips this frame and the deprecated method's, so the warning names
23
+ # the caller's file:line.
24
+ #
25
+ # Best-effort like OptionWarnings#emit: a closed or broken $stderr must
26
+ # not turn a still-supported call into an IOError. The name stays
27
+ # recorded either way (once per process means once).
28
+ #
29
+ # @param name [Symbol] the deprecated ClaudeAgentSDK module method
30
+ # @param replacement [String] the call to use instead, without the
31
+ # ClaudeAgentSDK. prefix
32
+ # @return [void]
33
+ def warn_once(name, replacement)
34
+ first = @mutex.synchronize { @warned.add?(name) }
35
+ return unless first
36
+
37
+ begin
38
+ warn("ClaudeAgentSDK.#{name} is deprecated and will be removed in 1.0; " \
39
+ "use ClaudeAgentSDK.#{replacement}", uplevel: 2)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Test hook: forget which deprecations were already reported.
46
+ def reset!
47
+ @mutex.synchronize { @warned.clear }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -23,6 +23,14 @@ module ClaudeAgentSDK
23
23
  # missing manifest entry, checksum mismatch).
24
24
  class CLIInstallError < ClaudeSDKError; end
25
25
 
26
+ # Raised by the local-disk session APIs (list_sessions, get_session_*,
27
+ # rename/tag/delete/fork_session, import_session_to_store) when the Claude
28
+ # config directory cannot be located: CLAUDE_CONFIG_DIR is unset and there
29
+ # is no usable home directory for the default ~/.claude (HOME unset with no
30
+ # passwd entry, as under `docker --user` in a minimal image, or an empty or
31
+ # relative HOME). Set CLAUDE_CONFIG_DIR to fix it.
32
+ class ConfigDirError < ClaudeSDKError; end
33
+
26
34
  # Raised when the CLI process fails
27
35
  class ProcessError < ClaudeSDKError
28
36
  attr_reader :exit_code, :stderr