claude-agent-sdk 0.35.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2976dae089e4ccc32b7974b646cb9e543c4dcdfd6bfb35eeccf6b3c4ef5e6ed
4
- data.tar.gz: 89ec51407ce6f25c962982078b3fe8301bfda9be4e57bf4a03593e9818d5913a
3
+ metadata.gz: aed11255947a3321978d4d9e65cf02ca0d88a6f8b2c9e61afede1de95c247b76
4
+ data.tar.gz: e5d54c2745b1264198a25569599c4f34ac6817778914f450d7aaca8ddb7e99d5
5
5
  SHA512:
6
- metadata.gz: 4784c6381acbfedd9a921a852329dfdd64257aa60b25bf51b0f4278b5fe6ce4913b1f719941a0fece9ac818342e5129c0ffd481159bf731ee6930ecf4902b632
7
- data.tar.gz: cee7e96d3ef502138189a114716db3862d10d2ff10411e04bf0db8bc654511c547397164c7640c590eed166d11556c26b5d4e6de744f3a6ac396cdb15e40bad3
6
+ metadata.gz: b5c4ce0e5b2a8594eafd236fcdd4d8592cf0f900b8eede62e7e680b50d3560ea99d04180e3b42550e863a95225e9cac191fe50e8baa7c376413832b051e1900b
7
+ data.tar.gz: b45fb3972949da75f83f845244e72b74fefcc88acf57c04ed89cbdde896a7e50eda69853a3430db88163259afe88862da0c598083745a3b9778f418ec001bf2d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.0] - 2026-09-23
11
+
12
+ The first step on the [road to 1.0](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126): one `session_store:` argument for every session function (the store-specific twins are deprecated), `ClaudeAgentSDK.ask`, String tool results, and the last audit follow-ups (#119–#121). **Read before upgrading:**
13
+ - An `exit`, `Interrupt` or signal raised inside a hook, `can_use_tool` or SDK MCP handler now **ends the process after the CLI gets its error response**. Since 0.34.0 a tool handler's `exit` was turned into an `isError` result and the process kept running, which also swallowed a real Ctrl-C or SIGTERM in `:inline` mode.
14
+ - The `*_from_store` / `*_via_store` session functions print a one-time deprecation warning; switch to `list_sessions(session_store: store)` etc. (table under **Deprecated**).
15
+ - Local-disk session APIs raise `ConfigDirError` (not `ArgumentError`) on hosts without a home directory; a session with no prompt has `first_prompt` `nil` on the disk path too (was `''`).
16
+
17
+ ### Added
18
+ - **SDK MCP tool handlers may return a String.** `create_tool('greet', ...) { |args| "Hello, #{args[:name]}!" }` now sends Claude a single text block, the same as returning `{ content: [{ type: 'text', text: "Hello, ..." }] }`. Both dispatch paths (`tools/call` through the MCP server and the direct `SdkMcpServer#call_tool`) accept it. Hash returns behave exactly as before and remain the form for `is_error`, `structured_content`, images and several blocks; any other non-Hash return still produces the in-band "must return a hash with :content key" error. The `create_tool` YARD examples, README and `docs/mcp-servers.md` (new "Handler Return Values" section) lead with the String form.
19
+ - **`ClaudeAgentSDK.ask(prompt, options: nil)`** — runs `query` to completion and returns the final `ResultMessage`, so `ClaudeAgentSDK.ask("What is 2 + 2?").result` is the answer text, with cost, usage and `session_id` on the same object. It is `query` underneath: same prompt types (String or Enumerable), same `options:` and `transport:`, same errors (a terminal error exit still raises `ResultError`; an `is_error` result that is not followed by an error exit is returned like any other). An optional block receives every message as it arrives, so callers can stream progress and still get the result. It consumes the whole stream and returns the last `ResultMessage`; if the stream ends without one it raises `CLIConnectionError`. The README Quick Start now leads with it.
20
+ - **Ruby-style `Client` method names next to the Python-parity ones**: `client.model = 'haiku'` (`set_model`), `client.permission_mode = 'plan'` (`set_permission_mode`), `client.context_usage` (`get_context_usage`) and `client.mcp_status` (`get_mcp_status`). Each delegates to its parity counterpart, so both spellings send the same control request, raise the same `CLIConnectionError` before `connect`, and an override of the parity method covers both. The parity names stay, so code ported from the Python docs keeps working. (`server_info` / `get_server_info` already existed as a pair.) `docs/client.md` lists both spellings.
21
+ - **`CLIInstaller.root` — a configurable root for the vendored CLI.** `CLIInstaller.default_dir` (and so `install`, `install_pinned`, `installed_path` without `dir:`, and the transport's discovery of the vendored binary) is `vendor/claude` under `CLIInstaller.root` when it is set, and under the working directory at call time when it is `nil`, the default. A process whose cwd is not the project root, such as a daemonized worker, can now find the vendored binary instead of falling through to `PATH`. The setter takes a String or Pathname, absolutizes it once, and rejects an empty path; `nil` restores the working-directory default. Unset, nothing changes, and an explicit `dir:` still wins.
22
+ - **The Railtie anchors CLI discovery to `Rails.root`**: an initializer sets `CLIInstaller.root ||= Rails.root` before `config/initializers` run, so an app initializer can override it and a root set in `config/application.rb` is kept. The `claude_agent_sdk:install_cli` task, which does not boot the app, installs under an explicitly set `CLIInstaller.root` if there is one, and under `Rails.root/vendor/claude` as before otherwise. The generated initializer's commented `cli_path:` line no longer describes a cwd workaround. `docs/cli-installer.md` gains a "Where `vendor/claude` is" section.
23
+ Groundwork for 1.0 ([#126](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues/126)): the sessions API collapses to one function per operation, and the store-specific twins are deprecated. Nothing is removed; every existing call keeps working.
24
+ - **`session_store:` on every session function.** `list_sessions`, `get_session_info`, `get_session_messages`, `list_subagents`, `get_subagent_metadata`, `get_subagent_messages`, `rename_session`, `tag_session`, `delete_session` and `fork_session` take an optional `session_store:`. Omitted or `nil`, they work on local disk exactly as before; given a store, they run the same code the `*_from_store` / `*_via_store` function did, with the same arguments. Two differences between the paths are documented in `docs/sessions.md`: `directory: nil` means every project on disk but the current working directory with a store (a store cannot enumerate projects), and `include_worktrees:` is disk-only: with `session_store:`, `list_sessions` accepts only the default `true` and raises `ArgumentError` for `false` or `nil` instead of silently ignoring the filter.
25
+ - **`ClaudeAgentSDK::ConfigDirError`** (a `ClaudeSDKError`), raised by the local-disk session APIs when the Claude config directory cannot be located: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory for the default `~/.claude`. Its message says to set `CLAUDE_CONFIG_DIR` (#120).
26
+ - CI: a macOS leg (Ruby 3.4) for the main suite; simplecov coverage (`COVERAGE=1 bundle exec rspec`) on the Linux Ruby 3.4 leg, with line/branch totals in the job summary and the HTML report as an artifact; and a weekly real-CLI integration run (`.github/workflows/integration.yml`) against `CLIInstaller::PINNED_CLI_VERSION`, also triggered by PRs that touch the installer. Dependabot keeps the workflows' actions current.
27
+ - `CONTRIBUTING.md`, `SECURITY.md`, and issue and pull request templates.
28
+
29
+ ### Changed
30
+ - **`exit` / `Interrupt` / signal exceptions from an SDK MCP tool handler terminate the process again, after the CLI gets its `isError` result.** Since #77 (0.34.0) they were converted into an `isError` result and the process kept running. That also swallowed a real Ctrl-C or `SIGTERM` landing in an `:inline` handler. The `isError` text now names the exception class (`"SystemExit: exit"` rather than `"exit"`). `SdkMcpServer#call_tool` and `#handle_message`, called directly without a session, let these exceptions propagate instead of returning an `isError` result. (#119)
31
+ - **Root-module plumbing is tagged `@api private`** and no longer appears in the generated YARD docs (`.yardopts` gains `--hide-api private`; `--no-private` alone never hid `@api private` objects): `resolve_observers`, `extract_sdk_mcp_servers`, `convert_hooks_to_internal_format`, `configure_can_use_tool`, `extract_exclude_dynamic_sections`, `extract_system_prompt_snapshot`, `notify_observers`, `check_inline_isolation`, `extract_user_prompt_text`, `prompt_text_from_content`, `observing_prompt_stream`, `flexible_fetch`, `normalize_tool_result`, and the tool-schema helpers `deep_symbolize_keys`, `deep_normalize_schema`, `prebuilt_json_schema?`, `normalize_tool_schema`, `ruby_type_to_json_schema`. They still work, but they are not part of the public API and move under `ClaudeAgentSDK::Internal` in 1.0. `fold_session_summary` stays public, since SessionStore adapters call it from `#append`. The existing `@api private` tags (`FiberBoundary` internals, `CancellationSignal#cancel`) are hidden too; `#cancel`'s tag is moved to its own line, where YARD recognizes it.
32
+ - `examples/rails_actioncable_example.rb` and `examples/rails_background_job_example.rb` use `ClaudeAgentSDK::Client.open` instead of hand-rolled `Async { connect … ensure disconnect }.wait`, matching `docs/rails.md`.
33
+ - RuboCop targets Ruby 3.2, the gemspec floor (was 3.0). The resulting autocorrections (anonymous block forwarding, dropping `require 'set'`) change no behavior.
34
+
35
+ ### Deprecated
36
+ - **The ten store-specific session functions**, removed in 1.0. Each still returns exactly what it did before (a `nil` `session_store:` still fails rather than falling back to disk), and prints a one-time warning per method per process naming its replacement and the calling line, e.g. `app/jobs/sync.rb:12: warning: ClaudeAgentSDK.list_sessions_from_store is deprecated and will be removed in 1.0; use ClaudeAgentSDK.list_sessions(session_store: store)`. The warning uses plain `Kernel#warn`, not `category: :deprecated`, because Ruby hides that category unless `Warning[:deprecated]` is enabled; `-W0` / `$VERBOSE = nil` silences it.
37
+
38
+ | Deprecated | Replacement |
39
+ |---|---|
40
+ | `list_sessions_from_store(session_store: s, ...)` | `list_sessions(session_store: s, ...)` |
41
+ | `get_session_info_from_store(session_store: s, ...)` | `get_session_info(session_store: s, ...)` |
42
+ | `get_session_messages_from_store(session_store: s, ...)` | `get_session_messages(session_store: s, ...)` |
43
+ | `list_subagents_from_store(session_store: s, ...)` | `list_subagents(session_store: s, ...)` |
44
+ | `get_subagent_metadata_from_store(session_store: s, ...)` | `get_subagent_metadata(session_store: s, ...)` |
45
+ | `get_subagent_messages_from_store(session_store: s, ...)` | `get_subagent_messages(session_store: s, ...)` |
46
+ | `rename_session_via_store(session_store: s, ...)` | `rename_session(session_store: s, ...)` |
47
+ | `tag_session_via_store(session_store: s, ...)` | `tag_session(session_store: s, ...)` |
48
+ | `delete_session_via_store(session_store: s, ...)` | `delete_session(session_store: s, ...)` |
49
+ | `fork_session_via_store(session_store: s, ...)` | `fork_session(session_store: s, ...)` |
50
+
51
+ All other arguments carry over unchanged. `import_session_to_store` is not affected.
52
+
53
+ ### Fixed
54
+ - **`exit`, `Interrupt` and signal exceptions raised while a hook, `can_use_tool`, or an SDK MCP resource reader / prompt generator runs no longer leave the CLI without an answer.** The SDK now responds first, then re-raises. The CLI gets exactly the response an ordinary exception from that callback produces, naming the exception class (`"SystemExit: exit"`, `"Interrupt"`, `"SignalException: SIGTERM"`). For hooks and `can_use_tool` that is the error control response; for `resources/read` / `prompts/get` it is a JSON-RPC `-32603` error. The transport flushes that response, and then the original exception propagates, so the process terminates as plain Ruby would: `exit 3` exits with status 3, and Ctrl-C interrupts. Previously no response was written and the reactor stopped. In the default `:thread` scheduling, Ruby also re-raised the worker thread's `SystemExit` on the main thread, which answered with a misleading `Cancelled`. This covers every dispatch site: `can_use_tool`, hooks with and without a `HookMatcher#timeout` (both variants), resources, prompts and tool handlers. It also covers a real Ctrl-C / `SIGTERM` that MRI delivers to the main thread while an `:inline` callback runs there, and a `:thread` hook that calls `exit` after its timeout expired (only `exit` is carried out of such an abandoned worker: an `Interrupt` or signal exception it raises ends that thread alone, as in plain Ruby). Cancellation (`Async::Stop`, hook timeouts) still propagates unchanged. A `callback_wrapper` sees an internal `StandardError` carrier whose `#cause` is the original, so ensure-based wrappers still clean up; the original is re-raised even if the wrapper swallows the carrier. Observers and message blocks are unchanged. (#119)
55
+ - **Session APIs on hosts without a home directory (#120).** With `CLAUDE_CONFIG_DIR` unset and `HOME` unset with no passwd entry (`docker --user` in a minimal image) or an empty/relative `HOME`:
56
+ - the local-disk session APIs (`list_sessions`, `get_session_*`, `list_subagents`, `rename_session` / `tag_session` / `delete_session` / `fork_session`, `import_session_to_store`) raise `ConfigDirError` instead of a bare `ArgumentError` from `~` expansion;
57
+ - a fresh session with a `session_store` no longer fails at connect. The transcript mirror cannot map the CLI's transcript files to store keys without a projects dir, so each unmappable batch is reported as a `MirrorErrorMessage` (with a `nil` key) and counted as dropped, while the session itself runs normally. `SessionStores.projects_dir` returns `nil` in this case instead of raising.
58
+ - **Store-backed resume seeds auth and settings from the home the CLI subprocess will use (#120).** When `options.env` sets `HOME`, `.credentials.json`, `settings.json` / `cowork_settings.json` and `.claude.json` are now read from under that home, as `CLAUDE_CONFIG_DIR` already was, instead of the parent process's home. An empty or relative `HOME` there, or `HOME => nil`, counts as no home, so those files are skipped. The transcript mirror resolves the subprocess's default `~/.claude/projects` the same way, so a `HOME` override no longer sends every mirror frame down the "not under projects dir" drop path.
59
+ - **Remaining disk/store session read inconsistencies (#121):**
60
+ - Store listings report `SDKSessionInfo#last_modified` as Integer epoch milliseconds, as documented, whatever shape the adapter's `mtime` has (ISO-8601 String, numeric String, Float, or now `Time`, e.g. an ActiveRecord `updated_at`). Previously only the ordering was coerced and the raw value leaked through. An unusable mtime (including non-finite numbers) reads as `0`, the value it already sorted by.
61
+ - `continue_conversation` with a `session_store` breaks equal-mtime ties by `session_id`, like the listings, instead of resuming whichever session the adapter listed first.
62
+ - `first_prompt` is `nil` on the disk path too (it was `''`) when a session has no prompt, matching the store path and the Python SDK.
63
+ - `cwd`: both paths take the first non-blank top-level `cwd`. The disk path took the first `cwd` even when empty (then fell back to the project path) and also matched `cwd` keys nested in tool inputs; the store fold now also skips whitespace-only values, so a sidecar no longer locks on one.
64
+ - `rename_session` / `tag_session` / `delete_session` / `fork_session` and their `_via_store` counterparts validate `session_id` (and `up_to_message_id`) at the boundary like the readers: a non-String id raises `ArgumentError` ("Invalid session_id") instead of `NoMethodError`.
65
+ - `list_sessions` deduplicates a session found in several project directories deterministically: newest `last_modified`, then the larger file, then the project directory that sorts first (the global scan now walks project directories in name order). Equal mtimes used to keep whichever copy the filesystem listed first.
66
+
10
67
  ## [0.35.0] - 2026-09-23
11
68
 
12
69
  First-class Rails integration and a first-impressions pass. **Rails users:** if your initializer uses the previously documented `->(inv) { Rails.application.executor.wrap { inv.call } }` callback wrapper, switch to `ClaudeAgentSDK::Railtie.callback_wrapper` — the bare form can deadlock in development (see **Fixed**).
data/README.md CHANGED
@@ -29,7 +29,7 @@ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-ove
29
29
 
30
30
  ```ruby
31
31
  # Gemfile
32
- gem 'claude-agent-sdk', '~> 0.35.0'
32
+ gem 'claude-agent-sdk', '~> 0.36.0'
33
33
  ```
34
34
 
35
35
  Then `bundle install`, or install directly with `gem install claude-agent-sdk`. To track unreleased changes, point the Gemfile at GitHub: `gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'`.
@@ -75,14 +75,23 @@ The block runs on a plain thread, so ActiveRecord calls inside it just work. [do
75
75
  ```ruby
76
76
  require 'claude_agent_sdk'
77
77
 
78
- ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
78
+ puts ClaudeAgentSDK.ask("What is 2 + 2?").result
79
+ ```
80
+
81
+ `ask` runs the whole conversation and returns the final `ResultMessage`: `#result` is the answer, and the same object carries `total_cost_usd`, `usage`, `session_id` and `structured_output` (`puts` on it prints a summary such as `[result: success, 1 turn, 2.1s, $0.0031]`). It takes the same prompt and `options:` as `query()`, and given a block it also yields every message as it arrives:
82
+
83
+ ```ruby
84
+ result = ClaudeAgentSDK.ask("Explain Ruby's GVL in three sentences") do |message|
79
85
  puts message.text if message.is_a?(ClaudeAgentSDK::AssistantMessage)
80
86
  end
87
+ puts result
81
88
  ```
82
89
 
90
+ It raises the same errors as `query()` (see [docs/errors.md](docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result.
91
+
83
92
  ### `query()` — one-shot and streaming
84
93
 
85
- `query()` runs a single conversation and yields each response message to the block.
94
+ `query()` runs a single conversation and yields each response message to the block. Reach for it over `ask` when you handle the messages yourself, or want to stop early with `break`.
86
95
 
87
96
  ```ruby
88
97
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(
@@ -145,7 +154,7 @@ Tools are Ruby blocks that run in-process, with no subprocess or IPC between Cla
145
154
 
146
155
  ```ruby
147
156
  greet = ClaudeAgentSDK.create_tool('greet', 'Greet a user', { name: :string }) do |args|
148
- { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
157
+ "Hello, #{args[:name]}!"
149
158
  end
150
159
 
151
160
  server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'my-tools', tools: [greet])
@@ -156,7 +165,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
156
165
  )
157
166
  ```
158
167
 
159
- Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details.
168
+ A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details.
160
169
 
161
170
  ### Hooks and permission callbacks
162
171
 
@@ -247,11 +256,11 @@ RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite (
247
256
  BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs
248
257
  ```
249
258
 
250
- CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4, and the Rails specs against Rails 7.1 and 8. See [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
259
+ CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8; a weekly job runs the integration suite against the pinned CLI. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
251
260
 
252
261
  ## Contributing
253
262
 
254
- Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change. Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md).
263
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md).
255
264
 
256
265
  ## License
257
266
 
@@ -33,6 +33,20 @@ Failures (unsupported platform, invalid version, HTTP error, response-size cap,
33
33
 
34
34
  **A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all.
35
35
 
36
+ ## Where `vendor/claude` is
37
+
38
+ With no `dir:`, `install`, `install_pinned` and `installed_path` use `CLIInstaller.default_dir`: `vendor/claude` under `CLIInstaller.root`, or under the process's working directory at call time while `root` is unset (the default). Transport discovery uses the same directory, so installing and finding the binary agree.
39
+
40
+ Set `root` when a process that runs agents does not start in the project root — a daemonized worker, a job runner launched from `/`, a systemd unit without `WorkingDirectory=`. Otherwise that process looks for `vendor/claude` under its own working directory, misses the vendored binary, and falls through to whatever `claude` is on `PATH`:
41
+
42
+ ```ruby
43
+ # early in boot, before the first query
44
+ ClaudeAgentSDK::CLIInstaller.root = '/srv/myapp' # a String or a Pathname
45
+ ClaudeAgentSDK::CLIInstaller.default_dir # => "/srv/myapp/vendor/claude"
46
+ ```
47
+
48
+ A relative path is resolved against the working directory once, when you set it. `nil` restores the working-directory default. In a Rails app you don't need this line: the Railtie sets `root` to `Rails.root` during boot (see [docs/rails.md](rails.md)). An explicit `dir:` argument always wins over `root`.
49
+
36
50
  > The vendored directory is trusted input: anything that can write to it can replace the binary the SDK executes. Keep it inside your deploy artifact, owned by the deploy user and not world-writable, exactly as you would treat `bin/`.
37
51
 
38
52
  ## Docker and `bin/setup`
@@ -62,7 +76,7 @@ require 'claude_agent_sdk/tasks' # loads only CLIInstaller, not the whole SDK
62
76
 
63
77
  ```bash
64
78
  bin/rails claude_agent_sdk:install_cli # Rails: installs PINNED_CLI_VERSION into Rails.root/vendor/claude
65
- rake claude_agent_sdk:install_cli # elsewhere: into vendor/claude under the working directory
79
+ rake claude_agent_sdk:install_cli # elsewhere: into CLIInstaller.default_dir (vendor/claude under the working directory unless root is set)
66
80
  rake claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # a version of your own, or 'stable' / 'latest'
67
81
  ```
68
82
 
@@ -83,6 +97,6 @@ The variable is deliberately not rake's conventional `VERSION`, which Rails' `db
83
97
  With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order:
84
98
 
85
99
  1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`)
86
- 2. The vendored binary (`CLIInstaller.installed_path`) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
100
+ 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
87
101
  3. `which claude`
88
102
  4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME)
data/docs/client.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `ClaudeAgentSDK::Client` supports bidirectional, interactive conversations with Claude Code. Unlike `query()`, `Client` enables **custom tools**, **hooks**, and **permission callbacks**, all of which can be defined as Ruby procs/lambdas. The Client class automatically uses streaming mode for bidirectional communication, allowing you to send multiple queries dynamically during a single session without closing the connection.
4
4
 
5
+ For a single question you don't need a session: `ClaudeAgentSDK.ask(prompt, options:)` runs `query()` to completion and returns the final `ResultMessage` (`#result` is the answer text), optionally yielding each message to a block on the way. See the README's [Quick Start](../README.md#quick-start).
6
+
5
7
  ## Basic Usage
6
8
 
7
9
  `Client.open` connects, yields the client, and always disconnects when the block exits (exceptions propagate after the disconnect). It returns the block's value, and creates an `async` reactor if it isn't already running inside one.
@@ -46,9 +48,10 @@ Async do
46
48
  client.connect
47
49
 
48
50
  client.interrupt # Send interrupt signal
49
- client.set_permission_mode('acceptEdits') # Change permission mode mid-conversation
50
- client.set_model('claude-sonnet-5') # Switch model mid-conversation
51
- status = client.get_mcp_status # Inspect MCP server status
51
+ client.permission_mode = 'acceptEdits' # Change permission mode mid-conversation
52
+ client.model = 'claude-sonnet-5' # Switch model mid-conversation (nil = default)
53
+ usage = client.context_usage # Context window usage by category
54
+ status = client.mcp_status # Inspect MCP server status
52
55
  info = client.get_server_info # Inspect server init info
53
56
  client.reconnect_mcp_server('my-server') # Reconnect a failed MCP server
54
57
  client.toggle_mcp_server('my-server', false) # Enable/disable an MCP server
@@ -62,6 +65,18 @@ Async do
62
65
  end.wait
63
66
  ```
64
67
 
68
+ The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged:
69
+
70
+ | Ruby style | Python-parity name |
71
+ |------------|--------------------|
72
+ | `client.model = 'haiku'` | `client.set_model('haiku')` |
73
+ | `client.permission_mode = 'plan'` | `client.set_permission_mode('plan')` |
74
+ | `client.context_usage` | `client.get_context_usage` |
75
+ | `client.mcp_status` | `client.get_mcp_status` |
76
+ | `client.server_info` | `client.get_server_info` |
77
+
78
+ Each Ruby-style method calls its parity counterpart, so both send the same control request and raise `CLIConnectionError` when the client is not connected. The one exception is `server_info`, which reads the cached initialization result and returns `nil` instead of raising before `connect`. As with any Ruby setter, `client.model = 'haiku'` evaluates to `'haiku'`, not to the control response.
79
+
65
80
  ## Custom Transport
66
81
 
67
82
  By default, `Client` uses `SubprocessCLITransport` to spawn the Claude Code CLI locally. You can provide a custom transport class to connect via other channels (e.g., remote SSH, WebSocket, or a sandbox VM).
data/docs/errors.md CHANGED
@@ -44,6 +44,15 @@ rescue ClaudeAgentSDK::CLIJSONDecodeError => e
44
44
  end
45
45
  ```
46
46
 
47
+ An ordinary exception raised inside your own callbacks (hooks, `can_use_tool`,
48
+ SDK MCP tool handlers, resource readers and prompt generators) is not raised
49
+ to the code above. It is reported to the CLI as a failed callback, and the
50
+ session keeps running. `exit`, `Interrupt` and other signal exceptions are the
51
+ exception: the CLI gets the error response first, and then they propagate as
52
+ Ruby normally would. See
53
+ [Hooks & Permission Callbacks](hooks-and-permissions.md#when-a-callback-raises)
54
+ and [MCP Servers](mcp-servers.md).
55
+
47
56
  ## Terminal Error Results
48
57
 
49
58
  When a run fails, the CLI emits a `result` message with `is_error: true` (which
@@ -104,6 +113,10 @@ class CLINotFoundError < CLIConnectionError
104
113
  # @param cli_path [String, nil] Optional path to the CLI that was not found
105
114
  end
106
115
 
116
+ # Raised by the local-disk session APIs when CLAUDE_CONFIG_DIR is unset and
117
+ # no usable home directory exists for the default ~/.claude
118
+ class ConfigDirError < ClaudeSDKError; end
119
+
107
120
  # Raised when the Claude Code process fails
108
121
  class ProcessError < ClaudeSDKError
109
122
  attr_reader :exit_code, # Integer | nil
@@ -138,9 +151,10 @@ end
138
151
  | Error | Description |
139
152
  |-------|-------------|
140
153
  | `ClaudeSDKError` | Base error for all SDK errors |
141
- | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect) |
154
+ | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and `ClaudeAgentSDK.ask` when the stream ends without a `ResultMessage` |
142
155
  | `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) |
143
156
  | `CLINotFoundError` | Claude Code not installed |
157
+ | `ConfigDirError` | A local-disk session API (`list_sessions`, `get_session_*`, `rename_session`, ...) could not locate the Claude config directory: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory (`HOME` unset with no passwd entry, as under `docker --user` in a minimal image, or an empty/relative `HOME`). Set `CLAUDE_CONFIG_DIR` |
144
158
  | `ProcessError` | Process failed (includes `exit_code` and `stderr`) — also raised when the CLI is still running 5s after closing stdout and the SDK had to terminate it |
145
159
  | `ResultError` | Run ended on a terminal error result (subclasses `ProcessError`; adds `subtype`, `errors`, `api_error_status`, `terminal_reason`, ...) — rescue it first |
146
160
  | `CLIJSONDecodeError` | JSON parsing issues — including stdout ending mid-frame (a truncated final message; `line` holds the partial frame) |
@@ -193,3 +193,25 @@ never raises — shadowing can be intentional, e.g. a callback used solely for
193
193
  tools outside `allowed_tools`. To gate every tool call including
194
194
  auto-approved ones, use a `PreToolUse` hook instead (note that a `PreToolUse`
195
195
  hook returning an allow decision also skips this callback).
196
+
197
+ ## When a callback raises
198
+
199
+ An exception raised inside a hook or a `can_use_tool` callback fails that
200
+ control request: the CLI receives an error response carrying the exception
201
+ message, and the session carries on with later requests. The request's
202
+ cancellation signal is invalidated, as for any other callback failure.
203
+
204
+ `exit`, `Interrupt` and other signal exceptions are never swallowed. If one
205
+ is raised while a callback runs (by the callback itself, or a real Ctrl-C /
206
+ `SIGTERM` arriving while an `:inline` callback runs on the main thread), the
207
+ CLI first gets the same error response, naming the exception class
208
+ (`"SystemExit: exit"`, `"Interrupt"`), and then the exception propagates as
209
+ Ruby normally would: `exit 3` ends the process with status 3, and Ctrl-C
210
+ interrupts it. This holds in both `:thread` and `:inline` scheduling, and also
211
+ when a `:thread` hook calls `exit` after its `HookMatcher#timeout` has already
212
+ expired. A `callback_wrapper` sees the exception wrapped in an internal
213
+ `StandardError` whose `#cause` is the original, so ensure-based wrappers (such
214
+ as `Rails.application.executor.wrap`) still clean up. The original is raised
215
+ again after the wrapper returns, even if the wrapper swallows the error.
216
+ Cancellation (`control_cancel_request`, `HookMatcher#timeout`, disconnect)
217
+ still propagates as before.
data/docs/mcp-servers.md CHANGED
@@ -14,7 +14,7 @@ greet_tool = ClaudeAgentSDK.create_tool(
14
14
  'greet', 'Greet a user', { name: :string },
15
15
  annotations: { title: 'Greeter', readOnlyHint: true }
16
16
  ) do |args|
17
- { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
17
+ "Hello, #{args[:name]}!"
18
18
  end
19
19
 
20
20
  server = ClaudeAgentSDK.create_sdk_mcp_server(
@@ -37,6 +37,27 @@ Async do
37
37
  end.wait
38
38
  ```
39
39
 
40
+ ## Handler Return Values
41
+
42
+ A handler returns either a String or a Hash:
43
+
44
+ - **A String** is sent to Claude as a single text block. `"Hello, Alice!"` is shorthand for `{ content: [{ type: 'text', text: "Hello, Alice!" }] }`.
45
+ - **A Hash** gives full control over the MCP result. `:content` (required) is an Array of MCP content blocks, so a tool can return several text blocks, images (`{ type: 'image', data: base64, mimeType: 'image/png' }`) and so on. Set `is_error: true` to tell Claude the call failed, and `structured_content:` to attach machine-readable output. Keys may be Symbols or Strings, and camelCase `isError` / `structuredContent` work too.
46
+
47
+ ```ruby
48
+ ClaudeAgentSDK.create_tool('lookup_order', 'Look up an order', { id: :string }) do |args|
49
+ order = Order.find_by(number: args[:id])
50
+ next { content: [{ type: 'text', text: "No order #{args[:id]}" }], is_error: true } unless order
51
+
52
+ {
53
+ content: [{ type: 'text', text: "Order #{order.number}: #{order.status}" }],
54
+ structured_content: { number: order.number, status: order.status }
55
+ }
56
+ end
57
+ ```
58
+
59
+ Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key.
60
+
40
61
  ## Pre-built JSON Schemas
41
62
 
42
63
  If your schemas come from another library (e.g., [RubyLLM](https://github.com/crmne/ruby_llm)) that deep-stringifies keys, the SDK handles them transparently — both symbol-keyed and string-keyed schemas are accepted and normalized:
@@ -80,15 +101,14 @@ ClaudeAgentSDK.create_tool('save', 'Save a fact', {
80
101
 
81
102
  ```ruby
82
103
  add_tool = ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
83
- result = args[:a] + args[:b]
84
- { content: [{ type: 'text', text: "#{args[:a]} + #{args[:b]} = #{result}" }] }
104
+ "#{args[:a]} + #{args[:b]} = #{args[:a] + args[:b]}"
85
105
  end
86
106
 
87
107
  divide_tool = ClaudeAgentSDK.create_tool('divide', 'Divide numbers', { a: :number, b: :number }) do |args|
88
108
  if args[:b] == 0
89
109
  { content: [{ type: 'text', text: 'Error: Division by zero' }], is_error: true }
90
110
  else
91
- { content: [{ type: 'text', text: "Result: #{args[:a] / args[:b]}" }] }
111
+ "Result: #{args[:a] / args[:b]}"
92
112
  end
93
113
  end
94
114
 
@@ -105,9 +125,13 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
105
125
 
106
126
  An exception raised inside a handler is returned to the model as an
107
127
  `isError: true` result carrying the exception message, so it can self-correct.
108
- This includes `exit`, `Interrupt` and other signal exceptions: they are reported
109
- the same way instead of escaping the session and leaving the CLI waiting on the
110
- tool call. Cancellation of the tool call itself still propagates.
128
+ `exit`, `Interrupt` and other signal exceptions are never swallowed: the CLI
129
+ first gets an `isError` result naming the exception class
130
+ (`"SystemExit: exit"`), so it is not left waiting on the tool call, and then
131
+ the exception propagates as Ruby normally would (`exit` ends the process,
132
+ Ctrl-C interrupts it). Called directly, without a session,
133
+ `SdkMcpServer#call_tool` and `#handle_message` simply let such exceptions
134
+ propagate. Cancellation of the tool call itself still propagates.
111
135
 
112
136
  ## Mixed Server Support
113
137
 
@@ -172,4 +196,10 @@ server = ClaudeAgentSDK.create_sdk_mcp_server(
172
196
  )
173
197
  ```
174
198
 
199
+ An exception raised inside a resource reader or prompt generator is answered
200
+ with a JSON-RPC internal error (`-32603`) carrying the exception message. For
201
+ `exit`, `Interrupt` and other signal exceptions the CLI gets that error first,
202
+ naming the exception class, and then the exception propagates as Ruby normally
203
+ would.
204
+
175
205
  See [examples/mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) for complete examples.
data/docs/rails.md CHANGED
@@ -25,7 +25,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the
25
25
  bin/rails claude_agent_sdk:install_cli CLAUDE_CLI_VERSION=x.y.z # or a version of your own ('stable' / 'latest' float)
26
26
  ```
27
27
 
28
- The binary lands in `Rails.root/vendor/claude`, where the SDK finds it ahead of any `claude` on `PATH` whenever the process runs from the app root, as `bin/rails`, Puma and most job runners do (otherwise set `cli_path:`; the initializer has it commented). The task does not boot the app (no database or credentials needed), so the same line works as a cached Docker build step: `RUN bin/rails claude_agent_sdk:install_cli`. Installs are checksum-verified and idempotent — see [docs/cli-installer.md](cli-installer.md). The CLI authenticates from the environment, e.g. `ANTHROPIC_API_KEY`.
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
29
 
30
30
  4. Run an agent from a job:
31
31
 
@@ -53,8 +53,7 @@ You do **not** need to think about this. By default (`callback_scheduling: :thre
53
53
 
54
54
  ```ruby
55
55
  tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
56
- user = User.find(args[:id]) # just works
57
- { content: [{ type: 'text', text: user.name }] }
56
+ User.find(args[:id]).name # just works
58
57
  end
59
58
 
60
59
  ClaudeAgentSDK.query(prompt: '...') do |message|
@@ -127,7 +126,7 @@ The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-b
127
126
  ```ruby
128
127
  tool = ClaudeAgentSDK.create_tool('lookup', 'Query legacy DB', { id: String }) do |args|
129
128
  row = ClaudeAgentSDK.offload { legacy_client.fetch(args[:id]) } # plain thread
130
- { content: [{ type: 'text', text: row.to_json }] }
129
+ row.to_json
131
130
  end
132
131
  ```
133
132
 
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