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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +68 -24
- data/docs/cli-installer.md +38 -1
- data/docs/client.md +44 -20
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +22 -0
- data/docs/mcp-servers.md +37 -7
- data/docs/rails.md +92 -54
- data/docs/sessions.md +69 -33
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +38 -8
- data/lib/claude_agent_sdk/deprecation.rb +51 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
- data/lib/claude_agent_sdk/option_warnings.rb +0 -2
- data/lib/claude_agent_sdk/query.rb +49 -8
- data/lib/claude_agent_sdk/railtie.rb +105 -0
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
- data/lib/claude_agent_sdk/session_mutations.rb +10 -10
- data/lib/claude_agent_sdk/session_resume.rb +19 -24
- data/lib/claude_agent_sdk/session_store.rb +28 -18
- data/lib/claude_agent_sdk/session_summary.rb +8 -3
- data/lib/claude_agent_sdk/sessions.rb +104 -18
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +37 -0
- data/lib/claude_agent_sdk/tasks.rb +13 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
- data/lib/claude_agent_sdk/types.rb +219 -3
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +261 -56
- 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 +17 -6
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`. 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
|
-
|
|
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:
|
|
73
|
+
callback_wrapper: ClaudeAgentSDK::Railtie.callback_wrapper
|
|
32
74
|
}
|
|
33
75
|
end
|
|
34
76
|
```
|
|
35
77
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
149
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
150
|
+
system_prompt: { type: 'preset', preset: 'claude_code' },
|
|
151
|
+
permission_mode: 'bypassPermissions'
|
|
152
|
+
)
|
|
104
153
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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).
|
|
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.
|
|
61
|
-
|
|
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
|
|
268
|
-
ISO-8601-string
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
`
|
|
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.
|
|
368
|
-
|
|
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)`.
|
|
@@ -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
|
|
55
|
-
# constant would freeze the working directory
|
|
56
|
-
# wrong for anything that chdirs (Rake
|
|
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, &
|
|
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, &
|
|
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
|
-
#
|
|
313
|
-
# current working directory
|
|
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
|