claude-agent-sdk 1.1.0 → 1.2.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/.yardopts +10 -0
- data/CHANGELOG.md +90 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +228 -77
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +94 -46
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- metadata +6 -4
data/docs/rails.md
CHANGED
|
@@ -16,7 +16,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the
|
|
|
16
16
|
bin/rails generate claude_agent_sdk:install
|
|
17
17
|
```
|
|
18
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`.
|
|
19
|
+
This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, [per-user isolation](#per-user-isolation), OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`.
|
|
20
20
|
|
|
21
21
|
3. Vendor the Claude Code CLI:
|
|
22
22
|
|
|
@@ -33,7 +33,10 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the
|
|
|
33
33
|
# app/jobs/summarize_ticket_job.rb
|
|
34
34
|
class SummarizeTicketJob < ApplicationJob
|
|
35
35
|
def perform(ticket)
|
|
36
|
-
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
36
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
37
|
+
tools: [], max_turns: 1, # text only, no built-in tools
|
|
38
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation"
|
|
39
|
+
)
|
|
37
40
|
prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
|
|
38
41
|
|
|
39
42
|
ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
|
|
@@ -43,24 +46,26 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the
|
|
|
43
46
|
end
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
The block runs on a plain thread (see the next section), so ActiveRecord calls inside it
|
|
49
|
+
The block runs on a plain thread (see the next section), so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). For multi-turn sessions and mid-session control (interrupt, model switching) use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. Hooks and custom tools work with `ClaudeAgentSDK.query` too.
|
|
47
50
|
|
|
48
51
|
## Thread-keyed libraries are safe inside SDK callbacks
|
|
49
52
|
|
|
50
53
|
The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread.
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
The SDK keeps your callbacks out of this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) it hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active, and thread-keyed libraries behave there as they do on any other thread:
|
|
53
56
|
|
|
54
57
|
```ruby
|
|
55
58
|
tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
|
|
56
|
-
User.find(args[:id]).name #
|
|
59
|
+
User.find(args[:id]).name # a connection of this thread's own: safe
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
ClaudeAgentSDK.query(prompt: '...') do |message|
|
|
60
|
-
Message.create!(role: 'assistant', body: message.to_s) #
|
|
63
|
+
Message.create!(role: 'assistant', body: message.to_s) # likewise
|
|
61
64
|
end
|
|
62
65
|
```
|
|
63
66
|
|
|
67
|
+
That thread is a new one, not the thread that called the SDK. The connection pool does not mind; everything Rails keeps for the current request or job does — `Current` attributes, `Time.zone`, log tags, the database role and shard are all back at their defaults inside a callback, and the callback is outside the caller's transaction. Read [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks) and [Transactions and the connection pool](#transactions-and-the-connection-pool) before a callback touches anything scoped to the request.
|
|
68
|
+
|
|
64
69
|
The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there.
|
|
65
70
|
|
|
66
71
|
### Rails executor around callbacks: `callback_wrapper`
|
|
@@ -75,20 +80,233 @@ ClaudeAgentSDK.configure do |config|
|
|
|
75
80
|
end
|
|
76
81
|
```
|
|
77
82
|
|
|
78
|
-
It runs each callback inside
|
|
83
|
+
It runs each callback inside the Rails executor — 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
84
|
|
|
80
85
|
- **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
86
|
- **`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**
|
|
87
|
+
- **Already inside the executor** — a callback that runs on the caller's own fiber, which under `:inline` scheduling is a `Client`'s message block and observers: it runs straight through, leaving cleanup to the enclosing executor. The other `:inline` callbacks run on fibers of their own, where under fiber isolation the executor is not active; they take one of the other paths.
|
|
88
|
+
|
|
89
|
+
Everywhere else — production, with no reloading — the callback runs inside the executor: its run hooks before the callback and its complete hooks after it, also when the callback raises. The configuration is read per call, so one initializer is correct in every environment.
|
|
90
|
+
|
|
91
|
+
**Development: an agent run still holds the reload lock.** The helper removes the deadlock, not the wait. A request or in-process job that runs an agent stays inside the executor, with its share of the interlock, until the run returns. Once a file changes — an agent editing your app does that — the next request asks to reload, the reloader waits for that share, and every other request waits behind the reloader. Rails treats any long request this way; it is not specific to the SDK. In development, run agent jobs in a separate process (`bin/jobs`, Sidekiq) rather than in a controller action or the in-process `:async` adapter, and point an agent that edits code at a different checkout than the one the server runs from. To see who is waiting for whom, add `config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks` and open `/rails/locks`.
|
|
92
|
+
|
|
93
|
+
**Errors and `Rails.error`.** The wrapper reports nothing to `Rails.error` itself, which is the one difference from `executor.wrap` (that reports whatever passes through it as an unhandled error). What your error tracker sees is therefore decided by where an exception ends up:
|
|
94
|
+
|
|
95
|
+
- An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. It is reported there, once, with that thread's context, by whatever runs the code inside the executor: the request middleware for a controller action, ActiveJob for a job a queue worker runs (`perform_later`). A `perform_now` called outside any executor — from a script or a console — raises it to its caller and reports nothing by itself.
|
|
96
|
+
- A failure the SDK handles is not reported: an exception in a hook or `can_use_tool` is answered to the CLI as an error response, one in an SDK MCP tool handler becomes an error result the model sees, one in an observer is swallowed, and a timed-out or cancelled callback is cancellation, not an error. To track these, report them where they happen: `rescue` inside the callback, call `Rails.error.report(e, handled: true)`, and re-raise.
|
|
97
|
+
|
|
98
|
+
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: the executor 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 are safe, including around a `break` from a message block.
|
|
83
99
|
|
|
84
|
-
|
|
100
|
+
Beyond the executor, this is a generic hook: APM spans, logging context, per-request state. To combine a wrapper of your own with the Rails one, call the Rails one from yours — and mind which side of that call your code is on. In production `rails.call` enters the Rails executor, and the executor starts every execution from a clean slate: on the way in it resets `CurrentAttributes` and the error context (`Rails.error.set_context`).
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
rails = ClaudeAgentSDK::Railtie.callback_wrapper
|
|
104
|
+
|
|
105
|
+
# What only has to surround the callback can stay outside:
|
|
106
|
+
->(invocation) { MyApm.trace('agent.callback') { rails.call(invocation) } }
|
|
107
|
+
|
|
108
|
+
# State that Rails resets per execution is set inside:
|
|
109
|
+
->(invocation) { rails.call(-> { Current.set(account: account) { invocation.call } }) }
|
|
110
|
+
|
|
111
|
+
# Not around it. This loses Current.account in every callback, in production only:
|
|
112
|
+
->(invocation) { Current.set(account: account) { rails.call(invocation) } }
|
|
113
|
+
```
|
|
85
114
|
|
|
86
|
-
|
|
115
|
+
The last form is a trap because it works in development, where the Rails wrapper stays out of the executor (see above), and because the loss looks selective: `Time.zone`, log tags and `connected_to` are not reset by the executor and survive on either side. `Current.set` does not carry the error context either; that needs `ActiveSupport::ExecutionContext.set`. The [recipe below](#carrying-the-callers-state-into-callbacks) puts all of it in the right place, and `spec/rails/callback_wrapper_composition_spec.rb` pins this rule against the executor hooks of both supported Rails versions.
|
|
87
116
|
|
|
88
117
|
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.
|
|
89
118
|
|
|
90
119
|
When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber.
|
|
91
120
|
|
|
121
|
+
## Request state does not follow into callbacks
|
|
122
|
+
|
|
123
|
+
A callback is written inside your controller action or job, a few lines below its `Current.set` or `connected_to` block — but it does not run there. It runs on a thread the SDK starts for it (the default `:thread` scheduling) or on a fiber of the SDK's reactor (`:inline`), and Rails keeps what belongs to the current request or job per thread or per fiber. A thread or fiber Rails never set up reads all of it as the default:
|
|
124
|
+
|
|
125
|
+
| Set on the caller | What a callback sees |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `Current.user = alice` (any `ActiveSupport::CurrentAttributes`) | `nil` |
|
|
128
|
+
| `Time.zone = "Tokyo"`, `Time.use_zone` | the application's default zone (`UTC` unless configured) |
|
|
129
|
+
| log tags: `Rails.logger.tagged("req-123")`, the request id, ActiveJob's job id | none |
|
|
130
|
+
| `connected_to(role: :reading)` | `:writing` |
|
|
131
|
+
| `connected_to(shard: :tenant_b)` | `:default` |
|
|
132
|
+
| `connected_to(prevent_writes: true)` | writes allowed |
|
|
133
|
+
| the error context: `Rails.error.set_context`, the controller or job Rails records | empty |
|
|
134
|
+
| `I18n.locale = :de` | depends on the i18n version — see below |
|
|
135
|
+
| the OpenTelemetry context | kept — the SDK carries it across |
|
|
136
|
+
|
|
137
|
+
This is the same in a message block, an observer, a hook, `can_use_tool` and an SDK MCP tool handler; with and without `Railtie.callback_wrapper`; in development and in production. It is the same under `callback_scheduling: :inline` with fiber isolation, with one exception: there a `Client`'s message block and observers run on the fiber that called the SDK, and see its state. (`ClaudeAgentSDK.query` runs every callback on another fiber, and hooks, `can_use_tool` and tool handlers always do.)
|
|
138
|
+
|
|
139
|
+
What a callback gets of `I18n.locale` depends on where i18n keeps its configuration:
|
|
140
|
+
|
|
141
|
+
| i18n | Callback on a thread of its own (`:thread` scheduling) | Callback on another fiber of the caller's thread (`:inline`) |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| 1.14.7 and earlier: a fiber-local | the default locale | the default locale |
|
|
144
|
+
| 1.14.8: a thread variable | the default locale | `:de` — every fiber of the thread shares one locale |
|
|
145
|
+
| 1.15 and later: fiber storage | `:de` — new threads and fibers inherit it | `:de` |
|
|
146
|
+
|
|
147
|
+
Nothing raises. The consequences are silent:
|
|
148
|
+
|
|
149
|
+
- A job inside `connected_to(shard: :tenant_b)` writes its own records to `tenant_b`; the same `create!` in a tool handler or in the message block writes to the **default** shard. Inside `connected_to(role: :reading)` the caller's own write raises `ActiveRecord::ReadOnlyError`; the same write in a callback succeeds, on the primary.
|
|
150
|
+
- A scope or policy that reads `Current.account` runs unscoped. Log lines written by callbacks carry no request id, errors reported from them no controller or job, and times are formatted in the default zone.
|
|
151
|
+
|
|
152
|
+
### Carrying the caller's state into callbacks
|
|
153
|
+
|
|
154
|
+
The SDK has no option for this yet. The supported way today is a wrapper of your own that captures the state **on the caller** and restores it **inside** the Rails wrapper, on the callback's thread or fiber:
|
|
155
|
+
|
|
156
|
+
<!-- spec/rails/request_state_spec.rb runs the next code block verbatim -->
|
|
157
|
+
```ruby
|
|
158
|
+
# app/lib/agent_context.rb
|
|
159
|
+
module AgentContext
|
|
160
|
+
# A callback_wrapper that runs SDK callbacks under the state of whoever
|
|
161
|
+
# calls this method. Call it from the request or job, once per SDK call.
|
|
162
|
+
def self.callback_wrapper
|
|
163
|
+
rails = ClaudeAgentSDK::Railtie.callback_wrapper
|
|
164
|
+
origin = Fiber.current
|
|
165
|
+
restore = capture
|
|
166
|
+
|
|
167
|
+
lambda do |invocation|
|
|
168
|
+
# The callback is running on the caller itself: the state is there.
|
|
169
|
+
next rails.call(invocation) if Fiber.current.equal?(origin)
|
|
170
|
+
|
|
171
|
+
# Restore inside the Rails wrapper: the executor it enters starts
|
|
172
|
+
# every execution from a clean slate.
|
|
173
|
+
rails.call(-> { restore.call(invocation) })
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Snapshots the caller. Returns a lambda that runs an invocation under
|
|
178
|
+
# that snapshot, on whatever thread or fiber it is called on.
|
|
179
|
+
def self.capture
|
|
180
|
+
attributes = Current.attributes.dup
|
|
181
|
+
context = ActiveSupport::ExecutionContext.to_h
|
|
182
|
+
locale = I18n.locale
|
|
183
|
+
zone = Time.zone
|
|
184
|
+
tags = log_tags
|
|
185
|
+
|
|
186
|
+
lambda do |invocation|
|
|
187
|
+
Current.set(attributes) do
|
|
188
|
+
ActiveSupport::ExecutionContext.set(**context) do
|
|
189
|
+
I18n.with_locale(locale) do
|
|
190
|
+
Time.use_zone(zone) do
|
|
191
|
+
with_log_tags(tags) { invocation.call }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The tags of the first tagged logger Rails.logger writes to. Not
|
|
200
|
+
# Rails.logger.formatter: in a broadcast that can be a plain logger's.
|
|
201
|
+
def self.log_tags
|
|
202
|
+
loggers = Rails.logger.respond_to?(:broadcasts) ? Rails.logger.broadcasts : [Rails.logger]
|
|
203
|
+
formatter = loggers.map { |logger| logger.formatter if logger.respond_to?(:formatter) }
|
|
204
|
+
.find { |candidate| candidate.respond_to?(:current_tags) }
|
|
205
|
+
formatter ? formatter.current_tags.dup : []
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# push / pop, not `Rails.logger.tagged(*tags) { ... }`: a BroadcastLogger
|
|
209
|
+
# runs that block once for every tagged logger it broadcasts to.
|
|
210
|
+
def self.with_log_tags(tags)
|
|
211
|
+
logger = Rails.logger
|
|
212
|
+
return yield if tags.empty? || !logger.respond_to?(:push_tags)
|
|
213
|
+
|
|
214
|
+
logger.push_tags(*tags)
|
|
215
|
+
begin
|
|
216
|
+
yield
|
|
217
|
+
ensure
|
|
218
|
+
logger.pop_tags(tags.size)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Build the wrapper where the state is set, and pass it in that call's options:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class SummarizeTicketJob < ApplicationJob
|
|
228
|
+
def perform(ticket)
|
|
229
|
+
Current.account = ticket.account
|
|
230
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
231
|
+
callback_wrapper: AgentContext.callback_wrapper, # replaces the configured Rails wrapper, and calls it
|
|
232
|
+
tools: [], max_turns: 1,
|
|
233
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
ClaudeAgentSDK.query(prompt: "Summarize:\n\n#{ticket.body}", options: options) do |message|
|
|
237
|
+
# Current.account, the locale, Time.zone, the job's log tags and error context are set here
|
|
238
|
+
ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
What the recipe depends on:
|
|
245
|
+
|
|
246
|
+
- **One wrapper per call, built on the caller.** `AgentContext.callback_wrapper` snapshots whoever calls it. Call it from the request or job, after its state is set and before `query` / `Client.open` — not inside their blocks, which may already run on another fiber. It cannot go into the initializer: a process-wide wrapper only ever runs at the destination and has no caller to look at. And it must not outlive the call: a wrapper is fixed for the lifetime of the session it was passed to, so one built when a long-lived `Client` connects makes every later turn run its callbacks as the **first** caller — another user's turn would read and write under the first user's account and shard. Open a session per request or job and resume it by id, as the examples below do, and the capture happens once per call.
|
|
247
|
+
- **The restore happens inside `rails.call`.** In production the Rails wrapper enters the executor, and the executor starts every execution from a clean slate: it resets `Current` and the error context. State set around `rails.call` is wiped on the way in; state set inside it survives.
|
|
248
|
+
- **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time.
|
|
249
|
+
- **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every tagged logger and run nothing. The tags are read from the first logger in the broadcast that keeps tags rather than from `Rails.logger.formatter`, which is the first logger's on Rails 8.1, tagged or not, and `nil` on 7.1 for a broadcast you built yourself. With no tagged logger at all the recipe carries no tags and still runs.
|
|
250
|
+
- **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails.
|
|
251
|
+
- **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host, keeping jobs' locales apart takes i18n 1.15 or later: on 1.14.8 the jobs of one worker share a locale among themselves, with or without the SDK. `:thread` scheduling takes the recipe's restore off that shared thread; it does not separate the jobs.
|
|
252
|
+
- **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread.
|
|
253
|
+
|
|
254
|
+
An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
role, shard = ApplicationRecord.current_role, ApplicationRecord.current_shard
|
|
258
|
+
prevent_writes = ApplicationRecord.current_preventing_writes
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call }`:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
with_log_tags(tags) do
|
|
265
|
+
ApplicationRecord.connected_to(role: role, shard: shard, prevent_writes: prevent_writes) { invocation.call }
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them.
|
|
270
|
+
|
|
271
|
+
What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` cases for whichever i18n the bundle resolves (CI's resolve 1.15 or later; the 1.14.7 and 1.14.8 rows were run against those releases), and runs the `AgentContext` block exactly as printed above through all five kinds of callback — also with two tagged loggers in the `Rails.logger` broadcast, and with a plain logger ahead of the tagged one. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite.
|
|
272
|
+
|
|
273
|
+
## Transactions and the connection pool
|
|
274
|
+
|
|
275
|
+
For the same reason — another thread — a callback is outside the caller's database transaction, on a connection of its own. Wrapping an SDK call in `transaction` or `with_lock` does not do what it looks like:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
ticket.with_lock do # the job's connection holds the row lock
|
|
279
|
+
ClaudeAgentSDK.query(prompt: prompt) do |message|
|
|
280
|
+
next unless message.is_a?(ClaudeAgentSDK::ResultMessage)
|
|
281
|
+
|
|
282
|
+
ticket.update!(summary: message.result) # another connection: waits for that lock,
|
|
283
|
+
end # while the job waits for this block
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- A callback does not see rows the caller has not committed.
|
|
288
|
+
- What a callback writes is committed on its own connection. Rolling the caller's transaction back does not undo it.
|
|
289
|
+
- A callback that needs a lock the caller's transaction holds waits for the caller, which is waiting for the callback. SQLite gives up after its busy timeout (`database is locked`); a server database keeps the callback waiting for as long as its lock timeout allows, which for PostgreSQL is forever by default. No database can detect the cycle, because half of it is in your process.
|
|
290
|
+
|
|
291
|
+
So finish the transaction **before** you call the SDK, and hand the callbacks what they need explicitly — ids rather than records in an unsaved state:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
ticket.update!(state: 'summarizing') # committed before the agent starts
|
|
295
|
+
ticket_id = ticket.id
|
|
296
|
+
|
|
297
|
+
ClaudeAgentSDK.query(prompt: prompt) do |message|
|
|
298
|
+
next unless message.is_a?(ClaudeAgentSDK::ResultMessage)
|
|
299
|
+
|
|
300
|
+
Ticket.find(ticket_id).update!(summary: message.result, state: 'summarized') # commits here, independently
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Pool size.** A callback that uses the database needs a connection while the caller may still be holding one: inside a transaction, under an explicit lease, and for the whole request or job on Rails 7.1, where a connection stays with its thread until the request ends. And one session can have several callbacks in the database at the same moment — the SDK answers the CLI's hook, permission and tool requests concurrently, so two parallel tool calls mean two hooks running at once. Size the pool for the caller's connection **plus the peak number of callback invocations that use the database at the same time**, summed over the sessions a process runs concurrently — not for one extra connection per session. With a pool of one and the caller inside a transaction, the first query in a callback raises `ActiveRecord::ConnectionTimeoutError`.
|
|
305
|
+
|
|
306
|
+
Under `callback_scheduling: :inline` with fiber isolation all of this applies to every callback that runs on a fiber other than the caller's: hooks, permission callbacks, tool handlers, and everything under `ClaudeAgentSDK.query`. Only a `Client`'s message block and observers share the caller's connection and transaction there.
|
|
307
|
+
|
|
308
|
+
(Measured with ActiveRecord 8.1 on SQLite; the PostgreSQL and MySQL lock waits follow from how those databases wait for row locks and were not measured.)
|
|
309
|
+
|
|
92
310
|
## Fiber workers (solid_queue) and `callback_scheduling: :inline`
|
|
93
311
|
|
|
94
312
|
[solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end:
|
|
@@ -111,9 +329,13 @@ ClaudeAgentSDK.configure do |config|
|
|
|
111
329
|
end
|
|
112
330
|
```
|
|
113
331
|
|
|
114
|
-
With `:inline`,
|
|
332
|
+
With `:inline`, no user callback leaves the job's reactor: message blocks, hooks, permission callbacks, SDK MCP handlers and observers all run in place, on a fiber of that reactor. This is the same execution model as the Python SDK (async callbacks run natively on the event loop).
|
|
115
333
|
|
|
116
|
-
|
|
334
|
+
In place on the reactor is not the same as on the job's own fiber, and under fiber isolation the fiber is what Rails keys the job's state on. A `Client`'s message block and observers run on the fiber that called the SDK. Hooks, permission callbacks and SDK MCP handlers run on child fibers of the session's read task, and `ClaudeAgentSDK.query` runs its whole body, message block included, on a fiber of its own. On those fibers `Current`, `Time.zone`, the log tags and the database role and shard start from their defaults, exactly as on a callback thread — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks).
|
|
335
|
+
|
|
336
|
+
Concretely:
|
|
337
|
+
|
|
338
|
+
- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool, as in the rest of your fiber-worker jobs.
|
|
117
339
|
- No per-call threads exist, so nothing can strand an AR connection.
|
|
118
340
|
- 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.
|
|
119
341
|
- 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.
|
|
@@ -148,7 +370,8 @@ class ChatAgentJob < ApplicationJob
|
|
|
148
370
|
def perform(chat_id, message_content)
|
|
149
371
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
150
372
|
system_prompt: { type: 'preset', preset: 'claude_code' },
|
|
151
|
-
permission_mode: 'bypassPermissions'
|
|
373
|
+
permission_mode: 'bypassPermissions',
|
|
374
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # one job class serves every chat: see "Per-user isolation"
|
|
152
375
|
)
|
|
153
376
|
|
|
154
377
|
ClaudeAgentSDK::Client.open(options: options) do |client|
|
|
@@ -173,6 +396,18 @@ end
|
|
|
173
396
|
|
|
174
397
|
`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
398
|
|
|
399
|
+
### Per-user isolation
|
|
400
|
+
|
|
401
|
+
One job class serves every chat here, from one working directory — and the CLI keeps an auto-memory per *project directory*, not per session or per user:
|
|
402
|
+
|
|
403
|
+
- Every SDK session reads that project's memory index. The CLI injects it next to the `CLAUDE.md` instructions, under the same "these instructions override default behavior" header.
|
|
404
|
+
- A session on the `claude_code` preset also **writes** it when a user asks it to remember something, and that write passes no permission check: no `permission_mode`, `can_use_tool` callback or hook is consulted.
|
|
405
|
+
- `setting_sources: []` isolates settings files. It does not turn this off.
|
|
406
|
+
|
|
407
|
+
So in a multi-user app one user's "remember that…" becomes part of every other user's context. `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` turns auto-memory off for the session. Every set of options built on this page carries it (`spec/rails/session_isolation_spec.rb` checks each one). Set it for every session a multi-user app runs — per call, as here, or once for the whole process by uncommenting the line the generated initializer carries; defaults merge per key, so a per-call `env:` keeps it, and the short snippets on this page that pass no options get it too. The value has to be `'1'`: the CLI reads `'0'` or `'false'` as "force auto-memory on", which overrides even `autoMemoryEnabled: false` in settings. To see what a session loaded, call `client.context_usage[:memoryFiles]` (an entry with `type: "AutoMem"` is the memory index); it costs no model call.
|
|
408
|
+
|
|
409
|
+
What else isolates sessions, and what only appears to, is covered in [Session isolation](configuration.md#session-isolation).
|
|
410
|
+
|
|
176
411
|
## Session Resumption
|
|
177
412
|
|
|
178
413
|
Persist Claude sessions for multi-turn conversations:
|
|
@@ -195,8 +430,12 @@ class ChatSession < ApplicationRecord
|
|
|
195
430
|
private
|
|
196
431
|
|
|
197
432
|
def build_options
|
|
198
|
-
opts = {
|
|
199
|
-
|
|
433
|
+
opts = {
|
|
434
|
+
permission_mode: 'bypassPermissions',
|
|
435
|
+
setting_sources: [], # isolates settings files, not the CLI's auto-memory:
|
|
436
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, # this does (see "Per-user isolation")
|
|
437
|
+
resume: claude_session_id.presence # nil for the first message: a new session
|
|
438
|
+
}
|
|
200
439
|
ClaudeAgentSDK::ClaudeAgentOptions.new(**opts)
|
|
201
440
|
end
|
|
202
441
|
end
|
|
@@ -214,7 +453,12 @@ class ClaudeAgentJob < ApplicationJob
|
|
|
214
453
|
def perform(task_id)
|
|
215
454
|
task = Task.find(task_id)
|
|
216
455
|
|
|
217
|
-
|
|
456
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
457
|
+
max_turns: 10,
|
|
458
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
ClaudeAgentSDK::Client.open(options: options) do |client|
|
|
218
462
|
client.query(task.prompt)
|
|
219
463
|
client.receive_response do |message|
|
|
220
464
|
task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
|
|
@@ -241,7 +485,8 @@ mcp_servers = {
|
|
|
241
485
|
|
|
242
486
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
243
487
|
mcp_servers: mcp_servers,
|
|
244
|
-
permission_mode: 'bypassPermissions'
|
|
488
|
+
permission_mode: 'bypassPermissions',
|
|
489
|
+
env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation"
|
|
245
490
|
)
|
|
246
491
|
```
|
|
247
492
|
|
|
@@ -251,7 +496,7 @@ Add OpenTelemetry tracing to your Rails app with a single initializer:
|
|
|
251
496
|
|
|
252
497
|
```ruby
|
|
253
498
|
# config/initializers/opentelemetry.rb
|
|
254
|
-
require 'base64'
|
|
499
|
+
require 'base64' # a bundled gem since Ruby 3.4 — no Gemfile entry needed: Active Support 7.1+ depends on it
|
|
255
500
|
require 'opentelemetry/sdk'
|
|
256
501
|
require 'opentelemetry/exporter/otlp'
|
|
257
502
|
|
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. 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. 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.
|
|
5
|
+
Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. A `directory:` that was removed after its sessions were recorded (a deleted worktree) still names its project: the path is resolved as far as it exists, so those sessions can be listed, read, renamed, tagged, forked and deleted through it. 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. `rename_session` raises `ArgumentError` for a `title:` that is blank or not a usable String (`nil`, another type, invalidly encoded — a binary String, such as `File.binread` returns, is read as UTF-8), and `tag_session` for such a `tag:` — except `nil`, which clears the tag.
|
|
6
6
|
|
|
7
7
|
## Listing Sessions
|
|
8
8
|
|
|
@@ -25,9 +25,16 @@ 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. `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
|
|
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; `created_at` is the first top-level `timestamp`. `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 (`get_session_info` takes it from the store's `list_sessions`, or else `list_session_summaries`, row for the session — one extra adapter call; a store that implements neither reports the timestamp of the session's last entry instead, `0` when no entry has one).
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` and `created_at` at up to the first 1 MiB when those first 64 KiB hold none (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads:
|
|
31
|
+
|
|
32
|
+
- a custom title or tag appended while the session was still running stops being seen once more than 64 KiB of transcript follows it (`custom_title` falls back to the AI title or `nil`, `summary` to the next source, `tag` to `nil`) until the CLI resumes the session, which re-appends them at the end;
|
|
33
|
+
- a last-prompt entry that is only at the start of the file is not used for `summary`;
|
|
34
|
+
- `cwd` comes from the first 64 KiB: a first entry larger than that leaves it at the project path;
|
|
35
|
+
- a first prompt whose transcript line ends beyond the first 1 MiB is not found: `first_prompt` is the name of a slash command seen before it, or `nil` — and a session with neither that nor a title or last-prompt entry in its last 64 KiB is not listed from disk at all. Likewise `created_at` is `nil` when no line ending within the first 1 MiB carries a timestamp of its own.
|
|
36
|
+
|
|
37
|
+
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 (`directory:` listings: the directory's own copy, then the worktrees in the order `git worktree list` reports them, main worktree first).
|
|
31
38
|
|
|
32
39
|
## Reading Session Messages
|
|
33
40
|
|
|
@@ -40,7 +47,7 @@ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" }
|
|
|
40
47
|
ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20)
|
|
41
48
|
```
|
|
42
49
|
|
|
43
|
-
Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)).
|
|
50
|
+
Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). Messages come back in conversation order. The CLI writes one entry per content block, so an assistant turn with several tool calls comes back as several `assistant` messages and one `user` message per `tool_result`; every result follows the `tool_use` it answers and precedes the next assistant turn. Transcripts are read as UTF-8 whatever the process locale is; a line that does not parse (the last line of a session whose CLI was killed mid-write) is skipped, and bytes that are not valid UTF-8 inside a line that does parse come back as U+FFFD.
|
|
44
51
|
|
|
45
52
|
## Reading Subagent Transcripts
|
|
46
53
|
|
|
@@ -112,6 +119,8 @@ ClaudeAgentSDK.delete_session(
|
|
|
112
119
|
)
|
|
113
120
|
```
|
|
114
121
|
|
|
122
|
+
Delete only sessions whose CLI process has exited. A CLI that is still running the session does not notice the deletion: its next write recreates the file with only the entries it writes from then on, so the session comes back in listings holding just the later part of the conversation.
|
|
123
|
+
|
|
115
124
|
## Forking a Session
|
|
116
125
|
|
|
117
126
|
```ruby
|
|
@@ -129,7 +138,9 @@ ClaudeAgentSDK.fork_session(
|
|
|
129
138
|
)
|
|
130
139
|
```
|
|
131
140
|
|
|
132
|
-
|
|
141
|
+
Without `title:` the fork is named after its source, `<title> (fork)`: the title the listing shows for the source (its custom title, else its AI title), else its first prompt, else `Forked session`. The disk and the store path use the same rule.
|
|
142
|
+
|
|
143
|
+
> `rename_session` and `tag_session` use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They, and `fork_session`, are safe to call while the session is open in a CLI process; `delete_session` is not (see [Deleting a Session](#deleting-a-session)). `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.
|
|
133
144
|
|
|
134
145
|
## Resuming at a Specific Message
|
|
135
146
|
|
|
@@ -261,10 +272,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
|
|
|
261
272
|
> `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one):
|
|
262
273
|
>
|
|
263
274
|
> - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
|
|
264
|
-
> subprocess can't consume it. On macOS with
|
|
275
|
+
> subprocess can't consume it. On macOS with no
|
|
265
276
|
> `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from
|
|
266
|
-
> the Keychain entry when one exists (the
|
|
267
|
-
> otherwise miss it)
|
|
277
|
+
> the CLI's Keychain entry for your config dir when one exists (the
|
|
278
|
+
> redirected config dir would otherwise miss it) — with a custom
|
|
279
|
+
> `CLAUDE_CONFIG_DIR`, only when that directory has no `.credentials.json`.
|
|
268
280
|
> - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else
|
|
269
281
|
> `~/.claude.json`).
|
|
270
282
|
> - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`,
|
|
@@ -276,7 +288,10 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
|
|
|
276
288
|
> Everything else in your config dir is **not** visible to the subprocess —
|
|
277
289
|
> notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the
|
|
278
290
|
> plugin keys stripped, user plugins are off) — so a store-backed resume can
|
|
279
|
-
> still behave differently from a plain `resume:` of the same session.
|
|
291
|
+
> still behave differently from a plain `resume:` of the same session. The
|
|
292
|
+
> project's auto-memory is part of that: the temp config dir has no
|
|
293
|
+
> `projects/<key>/memory/`, so a store-backed resume neither loads the memory
|
|
294
|
+
> you already have nor keeps what the session writes to it.
|
|
280
295
|
> Project-level `.claude/*` still applies (it resolves from `cwd`), and
|
|
281
296
|
> hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected.
|
|
282
297
|
> Seeded files are written owner-only (`0600`); a missing source file is simply
|
|
@@ -286,14 +301,26 @@ normal spawn path and `continue_conversation` moves on to the next candidate.
|
|
|
286
301
|
> (terminal append failures — timeouts immediately, other failures after up to
|
|
287
302
|
> three attempts — surfaced as `MirrorErrorMessage`):
|
|
288
303
|
> the store copy is then incomplete and the temp dir holds the only copy of the
|
|
289
|
-
> dropped turns, so the SDK
|
|
290
|
-
>
|
|
304
|
+
> dropped turns, so the SDK moves it into a fresh private directory next to it
|
|
305
|
+
> (`claude-preserved-resume-*`), keeps the transcripts (`projects/`) there,
|
|
306
|
+
> deletes everything else — the seeded credential and settings copies and
|
|
307
|
+
> whatever the CLI wrote beside them, such as its `backups/` copy of
|
|
308
|
+
> `.claude.json` — and warns with the new path so you can import them into the
|
|
309
|
+
> store. If what it moved is not the directory the SDK created (the path had
|
|
310
|
+
> been replaced, for instance by a symlink), or it cannot be moved, the SDK
|
|
311
|
+
> deletes nothing and the warning says the scrub was skipped and why; if an
|
|
312
|
+
> entry cannot be removed, the warning says the scrub failed and names what is
|
|
313
|
+
> left.
|
|
291
314
|
|
|
292
315
|
### Implementing an adapter
|
|
293
316
|
|
|
294
317
|
Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
|
|
295
318
|
`#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
|
|
296
319
|
`#list_session_summaries` are optional and probed via `SessionStore.implements?`.
|
|
320
|
+
An optional method that raises `NotImplementedError` when called counts as not
|
|
321
|
+
implemented too (the stub a delegating wrapper inherits, or an adapter declining
|
|
322
|
+
it at run time): the SDK takes the same fallback, and the conformance suite
|
|
323
|
+
skips that method's contracts.
|
|
297
324
|
Report `mtime` as epoch milliseconds; the SDK also accepts numeric-string,
|
|
298
325
|
ISO-8601-string and `Time` mtimes (ordering them correctly and reporting them
|
|
299
326
|
as Integer epoch ms in `last_modified`), but anything else sorts as oldest and
|
|
@@ -489,7 +516,8 @@ be resumed with `session_store:` + `resume:` from the original directory.
|
|
|
489
516
|
Re-importing appends the entries again, so adapters should dedupe by
|
|
490
517
|
`entry['uuid']`. It raises `ArgumentError` for an invalid `session_id` and
|
|
491
518
|
`Errno::ENOENT` when the transcript cannot be found; an unparseable line is
|
|
492
|
-
skipped with a warning
|
|
519
|
+
skipped with a warning, and bytes that are not valid UTF-8 inside a line that
|
|
520
|
+
does parse are stored as U+FFFD (an adapter could not serialize them).
|
|
493
521
|
|
|
494
522
|
> **Deprecated:** the separate store functions (`list_sessions_from_store`,
|
|
495
523
|
> `get_session_info_from_store`, `get_session_messages_from_store`,
|
data/docs/subagents.md
CHANGED
|
@@ -158,7 +158,7 @@ See [hook fields and permission cancellation](hooks-and-permissions.md).
|
|
|
158
158
|
|
|
159
159
|
## Minimal example
|
|
160
160
|
|
|
161
|
-
[`examples/subagent_status_example.rb`](
|
|
161
|
+
[`examples/subagent_status_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb)
|
|
162
162
|
defines a tool-free reviewer, registers lifecycle hooks, reads metadata, and
|
|
163
163
|
prints task events and forwarded child text without building a status model:
|
|
164
164
|
|