claude-agent-sdk 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +1 -1
- data/docs/cli-installer.md +17 -9
- data/docs/client.md +1 -1
- data/docs/configuration.md +5 -5
- data/docs/errors.md +4 -3
- data/docs/mcp-servers.md +22 -0
- data/docs/observability.md +6 -0
- data/docs/rails.md +2 -0
- data/docs/sessions.md +66 -15
- data/lib/claude_agent_sdk/cli_installer.rb +34 -18
- data/lib/claude_agent_sdk/command_builder.rb +11 -3
- data/lib/claude_agent_sdk/configuration.rb +54 -2
- data/lib/claude_agent_sdk/errors.rb +11 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
- data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
- data/lib/claude_agent_sdk/query.rb +146 -62
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +56 -4
- data/lib/claude_agent_sdk/session_mutations.rb +41 -16
- data/lib/claude_agent_sdk/session_resume.rb +112 -39
- data/lib/claude_agent_sdk/session_store.rb +19 -3
- data/lib/claude_agent_sdk/session_summary.rb +5 -5
- data/lib/claude_agent_sdk/sessions.rb +123 -55
- data/lib/claude_agent_sdk/streaming.rb +0 -8
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
- data/lib/claude_agent_sdk/types.rb +130 -36
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +49 -44
- metadata +16 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff96202bf91fc547d93ae77a7b022fb83a0651574fcb1aae5e521531e33ea9e7
|
|
4
|
+
data.tar.gz: 6956e7943d0856d9ac5975a7ba01e779159df1193301199dc0f082bf80835d88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e64d2bf6a83ae79b2f77007baad0d9ff340e4ff0b264d1acc066660860e57e329af84406bc041d39af73c1030afebf96d9f2a7894038354fdf6ce9847447e6c
|
|
7
|
+
data.tar.gz: 8e0e4e08914e30d812094a7a7cbfe4c0f128f7f83b3856a5947149b84566fd9ec72c8c9c3477778e3c196aba6694fb3524e84b3d4d1c1a919f1683aa545a271a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.34.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
The September 2026 audit campaign: 17 fixes from the final audit pass plus the 28 AUDIT-2026-09-22 issues (#66–#93). A few fixes tighten behaviour that was silently wrong — read **Changed** before upgrading.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`CLIInstaller::PINNED_CLI_VERSION`** (`'2.1.280'`, the CLI Python SDK 0.2.158 bundles) — the CLI version this gem release is developed and tested against; the Ruby equivalent of the Python SDK's bundled-CLI pin (`_cli_version.py`), except nothing is shipped inside the gem. Single source of truth: this constant is the only place the pin lives — docs and the transport's guidance reference it rather than repeating the literal.
|
|
16
|
+
- **`CLIInstaller.install_pinned(dir: nil)`** — installs exactly `PINNED_CLI_VERSION`. The Dockerfile / `bin/setup` form of "pin the tested pair": a deploy that calls it gets the SDK+CLI combination this release was tested with, and a Dependabot bump of the gem carries the CLI forward with it — no version literal in the caller to keep in sync. `install`'s default is unchanged (`'stable'` dist-tag), and explicit `version:` pins behave exactly as before.
|
|
17
|
+
- **`.github/workflows/cli-pin-bump.yml`** — scheduled (and manually dispatchable) workflow that reads the Python SDK's `_cli_version.py` on `main` and opens a PR moving `PINNED_CLI_VERSION` when it changes. It touches only that one line; cutting the follow-up patch release stays a human decision.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Runtime dependency floors now name versions that actually work: `async >= 2.10, < 3` and `mcp >= 0.22, < 2`** (were `~> 2.0` / `>= 0.20`). A new CI leg runs the suite against exactly these floors: async 2.0.x cannot run on Ruby 3.2+ at all, releases before 2.6.4 break hook timeouts and control-request error delivery, and 2.10 is the first with `Task#defer_stop`, which `Query#close` now relies on. `mcp` 0.20/0.21 fail every `tools/call` of a tool whose schema uses `$ref` into `$defs`. Only bundles pinned below these floors are affected. (#87)
|
|
21
|
+
- **`config.default_options = {...}` stores a frozen deep copy.** Change defaults by assigning a new Hash; in-place mutation of the stored defaults (`config.default_options[:model] = 'x'`, `merge!`, including on the initial empty Hash) now raises `FrozenError`, and later changes to the Hash you passed have no effect. The snapshot's Strings are frozen too: a session built from configured defaults shares them, so reassign (`options.model = 'opus'`) rather than mutate in place. Your own Hash and objects, and identity-bearing values such as SDK MCP server instances, are never frozen. (#92)
|
|
22
|
+
- **`rename_session_via_store` / `tag_session_via_store` raise `Errno::ENOENT` for a session the store has never seen** (`#load` returns nil or `[]`), like their disk counterparts and `fork_session_via_store`, instead of appending — and so creating — a phantom session that could never be deleted on append-only stores. Deliberately stricter than the Python SDK's store helpers. (#85)
|
|
23
|
+
- **`exit`, `Interrupt` and signal exceptions raised inside an SDK MCP tool handler are reported in-band** as an `isError: true` result, so the `tools/call` control response is always written; previously they escaped dispatch (and in the default `:thread` scheduling `exit` tore down the session). Cancellation still propagates. A `callback_wrapper` now observes a `RuntimeError` whose `#cause` is the original exception. Hooks and `can_use_tool` are tracked in #119. (#77)
|
|
24
|
+
- **Transcript mirror `eager` mode no longer guarantees one `SessionStore#append` per frame.** At most one background drain runs at a time; frames arriving while an append is in flight are coalesced, in order, into the next one (see Fixed, #84). Adapters must not assume one frame per call.
|
|
25
|
+
- **Subagent readers only accept `agent_id`s matching `[A-Za-z0-9._-]+`** (never `.` or `..`); anything else returns `[]` / `nil` without calling the adapter, so a path- or prefix-keyed `SessionStore` can't be re-routed through the synthesized `subagents/agent-<agent_id>` subpath (now documented in the adapter contract). (#79)
|
|
26
|
+
- **Blank session metadata reads the same on disk and in a store:** whitespace-only `git_branch` / `tag` are `nil` and a blank `cwd` falls back to the project path on the disk path too. (#67)
|
|
27
|
+
- **S3 reference adapter (`examples/session_stores/s3_session_store.rb`)** reserves each part number through a per-transcript `.sequence` object with conditional writes, so appends stay ordered across adapter instances and clock skew. Requires an S3-compatible endpoint with strong consistency and conditional `PUT`; stop old writers before upgrading (mixed old/new writers are unsupported), and quiesce writers before deleting sessions. Each uncontended append costs two extra requests.
|
|
28
|
+
- **`CLIInstaller`'s `VERSION` file records the target platform** (OS, architecture, libc) alongside version and checksum, and the offline shortcut requires all three to match, so a cache copied from another platform is reinstalled instead of trusted. Older one- or two-line metadata needs one online reinstall, then works offline again.
|
|
29
|
+
- **SDK MCP servers reject a tool whose input schema declares a top-level `server_context` property** at registration (the `mcp` gem would silently overwrite it with its own context), and a call that passes one to a composed/referenced/free-form schema gets an actionable `isError` result without invoking the handler.
|
|
30
|
+
- The transport's "Claude Code not found" guidance, `docs/cli-installer.md` and the skill references now point at `install_pinned` (interpolating the constant) instead of a hardcoded example version that went stale with every CLI release.
|
|
31
|
+
- Docs, examples and the bundled `claude-agent-ruby` skill now use current model IDs (`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`) and recommend adaptive thinking plus `effort:`; `ThinkingConfigEnabled(budget_tokens:)` is described as the older-model path. `examples/extended_thinking_example.rb` no longer teaches the deprecated `max_thinking_tokens`. No API changes.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
**Subprocess transport and teardown**
|
|
36
|
+
- **A stdin write cancelled mid-frame now poisons the transport instead of corrupting the session.** An `Async::Stop`, inline cooperative timeout or deadline landing while a large frame was parked on a full pipe left `ready?` true, so the next frame was appended to the partial one and every later message was invalid JSON. The original cancellation still propagates; every later write raises `CLIConnectionError` ("possible partial frame"). Recovery is a new session. (#80)
|
|
37
|
+
- **`close` and `end_input` no longer hang or strand a writer parked on a full stdin pipe.** A reactor fiber parked in the write is woken with the documented `CLIConnectionError`, and on Ruby 3.3+ a reactor-side close no longer hangs the whole reactor while a FiberBoundary worker thread is parked in the write. (#80)
|
|
38
|
+
- **`read_messages` no longer waits forever for a CLI that closes stdout and then hangs.** After stdout EOF the SDK waits 5 s, then TERM, then KILL, and raises `ProcessError` ("did not exit within 5s of closing stdout", negative-signal `exit_code`). (#73)
|
|
39
|
+
- **A truncated final stdout frame is reported** as `CLIJSONDecodeError` (`line` holds the partial frame) instead of the stream silently ending without its `ResultMessage`. Whitespace-only tails and reads cut short by `close` stay silent. (#89)
|
|
40
|
+
- **`Client#disconnect` called from inside an inline control-request callback, or from a streaming-input enumerator, now finishes the teardown before the caller unwinds.** The callback's task belongs to the tree the close stops, and the cascading `Async::Stop` used to interrupt `Query#close` before the transport and the close watcher were closed (a bare `Query#close` leaked the CLI process). The stop is now deferred through the teardown; the caller then unwinds with `Async::Stop` (use `ensure`, not `rescue StandardError`). A teardown error superseded by that Stop is warned on stderr. `:thread` mode, message blocks and observers are unchanged: `disconnect` returns normally there. (#81)
|
|
41
|
+
- **An outer `Async` timeout during `close` no longer makes the transport forget a still-live CLI process**: the timeout propagates and the TERM/KILL fallback keeps ownership. A cancelled close's background fallback releases the process from the at-exit registry only once it has actually been reaped.
|
|
42
|
+
- **Control requests fail promptly when the stream ends**: a clean read EOF broadcasts a terminal error to every waiting control request (they used to sit out the 1200 s timeout), and requests issued after EOF are rejected before writing. A control request's serialization, write and wait now share one deadline and one cleanup scope, so a failed or cancelled send never leaks its waiter and backpressure can't block past the configured timeout.
|
|
43
|
+
- **CLI stderr is scrubbed to valid UTF-8** for the `stderr` callback, `debug_stderr` and `ProcessError#stderr`, like stdout frames. (#90)
|
|
44
|
+
- **CLI discovery no longer accepts a non-executable file at a well-known install location** — a stray 0644 `~/.claude/local/claude` now ends in `CLINotFoundError` with install instructions instead of a raw `Errno::EACCES` at spawn. (#72)
|
|
45
|
+
- **Store-backed resume and CLI discovery work on hosts without a usable home directory** (`HOME` unset with no passwd entry, e.g. `docker --user` in a minimal image, or an empty/relative `HOME`): home-relative auth seeds and install locations are skipped instead of raising `ArgumentError`. `Sessions.config_dir` / `projects_dir` are tracked in #120. (#82)
|
|
46
|
+
- **Sandbox settings merging resolves a relative settings file against the CLI's `cwd`**, not the Ruby process's working directory.
|
|
47
|
+
- **`FiberBoundary` worker threads disable `report_on_exception` before the callback runs**, so a fast-failing callback is no longer also dumped to stderr. (#93)
|
|
48
|
+
|
|
49
|
+
**Options and configuration**
|
|
50
|
+
- **Typed option values are no longer shared by reference between sessions.** `SandboxSettings` (and its network/filesystem configs), `SystemPromptPreset` / `Custom` / `File`, `ToolsPreset`, `AgentDefinition`, `HookMatcher`, thinking configs, `TaskBudget`, `Mcp*ServerConfig` and `SdkPluginConfig` placed in `ClaudeAgentSDK.configure` defaults, or copied by `ClaudeAgentOptions#dup_with` (including values nested in `agents`, `hooks`, `mcp_servers`, `plugins`), are copied per instance via the new `Type#dup_for_options` hook — a per-session change to sandbox rules or a system prompt can no longer silently change what every other session sends to the CLI. Unfrozen Strings are copied too. Procs, observers and factories, SDK MCP server instances, session-store adapters and `callback_wrapper` keep their identity; CLI arguments are unchanged. (#69, #70)
|
|
51
|
+
- **`Configuration#default_options` is no longer a live Hash read without synchronization**: request-time merges read a private frozen snapshot, so a concurrent in-place write can no longer raise `can't add a new key into hash during iteration` or tear a merge. (#92)
|
|
52
|
+
- **String-keyed SDK MCP server configs are recognized** (`{ 'tools' => { 'type' => 'sdk', 'instance' => server } }`, and `type: :sdk`): the instance is registered in-process, stdin stays open for its tool calls, and it is never serialized into `--mcp-config`. (#68)
|
|
53
|
+
- **`Client` normalizes input like `query()`**: a bare Hash prompt is rejected before anything is spawned (it used to stream Ruby inspection strings), and `nil` / empty hook lists are accepted instead of crashing `connect`.
|
|
54
|
+
- **Class-valued shorthand tool schemas work**: the documented `{ id: Integer }` advertises and accepts an integer (it advertised a string and rejected integers); `Float`, `TrueClass` and `FalseClass` likewise.
|
|
55
|
+
- **`ResultError`'s message and `#api_error_status` share one Integer narrowing**, so a non-Integer status (e.g. `"500"`) no longer appears as `API error (HTTP 500)` while the accessor returns `nil`. (#76)
|
|
56
|
+
|
|
57
|
+
**Sessions and SessionStore**
|
|
58
|
+
- **A single unserializable store entry no longer aborts a store-backed resume.** Entries (and subagent metadata sidecars) that JSON can't encode — NaN/Infinity, invalid UTF-8, circular or over-deep nesting — are skipped with a warning naming the entry's `uuid`; well-formed entries are written byte-for-byte as before. A session with no usable entries behaves like an empty one, and `--continue` classifies sidechains from the first surviving entry. (#83)
|
|
59
|
+
- **Non-String subkeys from a `SessionStore` are rejected** like any other unsafe subkey instead of raising `NoMethodError` and aborting the resume. (#91)
|
|
60
|
+
- **Store-backed listings coerce adapter `mtime` values** (ISO-8601 or numeric strings, e.g. SQL timestamps through JSON): they list newest first — they came back oldest first, so `limit:` cut off the newest sessions — and mixed Integer/String mtimes no longer raise. (#66)
|
|
61
|
+
- **Session listings break equal-mtime ties by `session_id`**, so `offset:` / `limit:` pages are stable across calls and never skip or repeat sessions, and disk and store listings order identical input identically. (#78)
|
|
62
|
+
- **Disk and store paths agree on sidechain classification and blank summaries**: a corrupt, blank, non-object or invalidly encoded first transcript line no longer makes one path list a session the other hides (the disk reader now classifies from the first parseable entry, as the store path did), and whitespace-only (or invalidly encoded) summaries/titles are blank on both. (#67, #75)
|
|
63
|
+
- **Session read APIs validate ids at the boundary**: a non-String or invalidly encoded `session_id` / `agent_id` gets the same `nil` / `[]` as a malformed id (and `import_session_to_store` raises `ArgumentError`) instead of a deep `NoMethodError` / `ArgumentError`. (#74)
|
|
64
|
+
- **The transcript mirror no longer piles up background tasks when the `SessionStore` is slower than the CLI's frame rate**: at most one drain task runs, and later frames are buffered and coalesced in order, without ever slowing the read loop. (#84)
|
|
65
|
+
- **Rename and tag no longer corrupt a transcript whose last record lacks a trailing newline**: the metadata record is written on its own line.
|
|
66
|
+
- **Forked sessions are published atomically**: the fork is written to a private temporary file and hard-linked into place, so a failed fork never leaves a partial session under its final id (filesystems without hard links fail safely).
|
|
67
|
+
- **An explicitly cleared custom or AI title at the end of a long transcript stays cleared** on the disk path (it resurrected an older title from the file head).
|
|
68
|
+
- **`InMemorySessionStore` deep-copies entries and summaries** across `append` / `load` / summary boundaries, so callers can no longer mutate stored transcripts through the objects they passed in or got back.
|
|
69
|
+
- **The SessionStore conformance kit checks summary content** (latest title, first timestamp, first prompt, sidechain classification across appends and refolds), so an adapter returning empty or stale summaries now fails it.
|
|
70
|
+
- Docs and the bundled skill describe what store-backed resume really seeds (`settings.json` / `cowork_settings.json` included since 0.31). (#86)
|
|
71
|
+
|
|
72
|
+
**Observability**
|
|
73
|
+
- **`OTelObserver` records per-turn cost increments** for `gen_ai.usage.cost` / `llm.cost.total`; the CLI reports cumulative `total_cost_usd`, so summing spans used to double-count earlier turns.
|
|
74
|
+
- **OpenTelemetry parent context is preserved across the SDK's fibers and threads** (`query()` / `Client.open` reactors, Query's background tasks, FiberBoundary worker dispatch), so observer and callback spans attach to the caller's trace instead of starting detached.
|
|
75
|
+
|
|
76
|
+
**Examples and tooling**
|
|
77
|
+
- `examples/error_handling_example.rb`'s retry helper now retries what the SDK raises: `ResultError` only for transient API statuses (429, 529, other 5xx), then bare `ProcessError` with bounded backoff. (#88)
|
|
78
|
+
- CI runs the suite against the newest allowed majors (json 3.x, mcp 1.x) and the declared dependency floors. The git-less gemspec fallback packages the same files git would; `docs/errors.md` lists `ResultError`; flaky ordering sleeps in specs are gone. (#87, #93)
|
|
79
|
+
|
|
80
|
+
## [0.33.1] - 2026-09-21
|
|
81
|
+
|
|
82
|
+
Compatibility with `json` 3.x and `mcp` 1.x. Upgrade if your bundle resolves `json` 3.x — `rename_session` / `tag_session` raise on 0.33.0.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
- `mcp` dependency is now `>= 0.20, < 2` (was `>= 0.6, < 1`). The floor moves to 0.20 because `mcp` 0.19 and older validate through the `json-schema` gem, which breaks under `json` 3.x and fails every SDK MCP `tools/call`; with `json` 2.x those versions still pass, so this only forces an `mcp` upgrade on bundles pinned below 0.20. The suite passes against every 1.x release through 1.6.0, and `initialize` / `tools/list` / `tools/call` / `resources/*` / `prompts/*` wire output is byte-identical to 0.2x.
|
|
86
|
+
- SDK MCP tool handler exceptions now reach the model as the bare exception message (matching Python's `str(e)` and `SdkMcpServer#call_tool`) instead of the gem's `Internal error calling tool X: msg`. The exception is rescued inside the SDK's tool class, so the text no longer depends on the `mcp` gem version — `mcp` 1.2+ redacts the message from its own wrapper, which would otherwise have left the model with no error text to self-correct from. Still in-band `isError: true`.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
- `rename_session` / `tag_session` raised `ArgumentError: unknown keyword: space_size` under `json` 3.x, which takes generator options as strict keywords. The option was a no-op on `json` 2.x (output unchanged), so it is simply gone. A fresh end-user bundle resolves `json` 3.x through `async → console → json`; CI missed it only because the dev-only RuboCop pin holds `json` at 2.x.
|
|
90
|
+
|
|
10
91
|
## [0.33.0] - 2026-09-21
|
|
11
92
|
|
|
12
93
|
Subagent capabilities for UI builders: metadata reads, background snapshots, cooperative callback cancellation, and the task/background/permission signals the CLI already emits — all raw data and controls, no status model. Ruby-ahead of the Python SDK (0.2.153).
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-ove
|
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
30
|
+
gem 'claude-agent-sdk', '~> 0.34.0'
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
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'`.
|
data/docs/cli-installer.md
CHANGED
|
@@ -7,11 +7,18 @@ The SDK runs the `claude` CLI as a subprocess, so a deploy is only reproducible
|
|
|
7
7
|
```ruby
|
|
8
8
|
require 'claude_agent_sdk'
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# Install the CLI version this gem release was tested against
|
|
11
|
+
# (CLIInstaller::PINNED_CLI_VERSION) — recommended: bumping the gem
|
|
12
|
+
# then carries the CLI forward with it, e.g. via Dependabot.
|
|
13
|
+
ClaudeAgentSDK::CLIInstaller.install_pinned
|
|
12
14
|
# => "/app/vendor/claude/claude"
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# Or pin a concrete version of your own:
|
|
17
|
+
ClaudeAgentSDK::CLIInstaller.install(version: 'x.y.z', dir: '/opt/claude')
|
|
18
|
+
|
|
19
|
+
# 'stable' (the default) and 'latest' are floating dist-tags, not pins:
|
|
20
|
+
# they re-resolve on every call, so a rebuild may install a newer CLI.
|
|
21
|
+
ClaudeAgentSDK::CLIInstaller.install(version: 'stable')
|
|
15
22
|
|
|
16
23
|
# nil unless a binary is already installed there
|
|
17
24
|
ClaudeAgentSDK::CLIInstaller.installed_path
|
|
@@ -19,7 +26,7 @@ ClaudeAgentSDK::CLIInstaller.installed_path
|
|
|
19
26
|
|
|
20
27
|
`install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot:
|
|
21
28
|
|
|
22
|
-
- The install directory's `VERSION` file records the installed version
|
|
29
|
+
- The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again.
|
|
23
30
|
- An exclusive `flock` on `<dir>/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race; the loser simply observes the finished install.
|
|
24
31
|
|
|
25
32
|
Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`.
|
|
@@ -31,16 +38,17 @@ Failures (unsupported platform, invalid version, HTTP error, response-size cap,
|
|
|
31
38
|
## Docker and `bin/setup`
|
|
32
39
|
|
|
33
40
|
```dockerfile
|
|
34
|
-
# Dockerfile —
|
|
41
|
+
# Dockerfile — the gem's pinned CLI version in its own cached layer
|
|
35
42
|
RUN bundle exec ruby -e "require 'claude_agent_sdk'; \
|
|
36
|
-
ClaudeAgentSDK::CLIInstaller.
|
|
43
|
+
ClaudeAgentSDK::CLIInstaller.install_pinned"
|
|
37
44
|
```
|
|
38
45
|
|
|
39
46
|
```ruby
|
|
40
47
|
#!/usr/bin/env ruby
|
|
41
|
-
# bin/setup
|
|
48
|
+
# bin/setup — gem's pin by default, overridable per developer
|
|
42
49
|
require 'claude_agent_sdk'
|
|
43
|
-
|
|
50
|
+
version = ENV.fetch('CLAUDE_CLI_VERSION', ClaudeAgentSDK::CLIInstaller::PINNED_CLI_VERSION)
|
|
51
|
+
puts ClaudeAgentSDK::CLIInstaller.install(version: version)
|
|
44
52
|
```
|
|
45
53
|
|
|
46
54
|
## Supported platforms
|
|
@@ -54,4 +62,4 @@ With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in th
|
|
|
54
62
|
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:`)
|
|
55
63
|
2. The vendored binary (`CLIInstaller.installed_path`) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally
|
|
56
64
|
3. `which claude`
|
|
57
|
-
4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …)
|
|
65
|
+
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
|
@@ -38,7 +38,7 @@ Async do
|
|
|
38
38
|
|
|
39
39
|
client.interrupt # Send interrupt signal
|
|
40
40
|
client.set_permission_mode('acceptEdits') # Change permission mode mid-conversation
|
|
41
|
-
client.set_model('claude-sonnet-
|
|
41
|
+
client.set_model('claude-sonnet-5') # Switch model mid-conversation
|
|
42
42
|
status = client.get_mcp_status # Inspect MCP server status
|
|
43
43
|
info = client.get_server_info # Inspect server init info
|
|
44
44
|
client.reconnect_mcp_server('my-server') # Reconnect a failed MCP server
|
data/docs/configuration.md
CHANGED
|
@@ -58,7 +58,7 @@ Use the `effort` option to control the model's effort level:
|
|
|
58
58
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(effort: 'xhigh')
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Valid levels live in `ClaudeAgentSDK::EFFORT_LEVELS` (`low`, `medium`, `high`, `xhigh`, `max`). The set of *supported* levels is model-dependent — `xhigh` is available on Opus 4.7 and the CLI falls back to the highest supported level at or below the one you set (e.g. `xhigh` → `high` on Opus 4.6). When `effort` is `nil`, the CLI picks a model-native default (Opus 4.7 → `xhigh`).
|
|
61
|
+
Valid levels live in `ClaudeAgentSDK::EFFORT_LEVELS` (`low`, `medium`, `high`, `xhigh`, `max`). The set of *supported* levels is model-dependent — `xhigh` is available on Opus 4.7 and later models, and the CLI falls back to the highest supported level at or below the one you set (e.g. `xhigh` → `high` on Opus 4.6). When `effort` is `nil`, the CLI picks a model-native default (e.g. Opus 4.7 → `xhigh`).
|
|
62
62
|
|
|
63
63
|
> **Note:** When `system_prompt` is `nil` (the default), the SDK passes `--system-prompt ""` to the CLI, which suppresses the default Claude Code system prompt. To use the default system prompt, use a `SystemPromptPreset`.
|
|
64
64
|
|
|
@@ -119,8 +119,8 @@ See [examples/budget_control_example.rb](https://github.com/ya-luotao/claude-age
|
|
|
119
119
|
|
|
120
120
|
```ruby
|
|
121
121
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
122
|
-
model: 'claude-sonnet-
|
|
123
|
-
fallback_model: 'claude-
|
|
122
|
+
model: 'claude-sonnet-5',
|
|
123
|
+
fallback_model: 'claude-haiku-4-5'
|
|
124
124
|
)
|
|
125
125
|
```
|
|
126
126
|
|
|
@@ -135,8 +135,8 @@ receives the full conversation.
|
|
|
135
135
|
|
|
136
136
|
```ruby
|
|
137
137
|
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
138
|
-
model: 'haiku',
|
|
139
|
-
advisor_model: 'opus' #
|
|
138
|
+
model: 'claude-haiku-4-5',
|
|
139
|
+
advisor_model: 'claude-opus-5' # full model ID, or an alias such as 'opus'
|
|
140
140
|
)
|
|
141
141
|
```
|
|
142
142
|
|
data/docs/errors.md
CHANGED
|
@@ -138,11 +138,12 @@ end
|
|
|
138
138
|
| Error | Description |
|
|
139
139
|
|-------|-------------|
|
|
140
140
|
| `ClaudeSDKError` | Base error for all SDK errors |
|
|
141
|
-
| `CLIConnectionError` | Connection issues |
|
|
141
|
+
| `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect) |
|
|
142
142
|
| `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) |
|
|
143
143
|
| `CLINotFoundError` | Claude Code not installed |
|
|
144
|
-
| `ProcessError` | Process failed (includes `exit_code` and `stderr`) |
|
|
145
|
-
| `
|
|
144
|
+
| `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
|
+
| `ResultError` | Run ended on a terminal error result (subclasses `ProcessError`; adds `subtype`, `errors`, `api_error_status`, `terminal_reason`, ...) — rescue it first |
|
|
146
|
+
| `CLIJSONDecodeError` | JSON parsing issues — including stdout ending mid-frame (a truncated final message; `line` holds the partial frame) |
|
|
146
147
|
| `MessageParseError` | Message parsing issues |
|
|
147
148
|
|
|
148
149
|
See [lib/claude_agent_sdk/errors.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/errors.rb) for all error types.
|
data/docs/mcp-servers.md
CHANGED
|
@@ -41,6 +41,17 @@ end.wait
|
|
|
41
41
|
|
|
42
42
|
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:
|
|
43
43
|
|
|
44
|
+
**Reserved argument name:** do not use `server_context` as a top-level tool
|
|
45
|
+
argument. The underlying MCP gem uses that keyword for injected request context
|
|
46
|
+
and would overwrite the user value before your handler runs. Registering a tool
|
|
47
|
+
whose root `properties` declare it raises `ArgumentError`, for both shorthand and
|
|
48
|
+
pre-built schemas (including directly constructed `SdkMcpTool` objects). For
|
|
49
|
+
composed, referenced, or free-form schemas, actual MCP calls containing this
|
|
50
|
+
top-level key return an actionable `isError: true` tool result before the handler
|
|
51
|
+
runs. This runtime guard remains active even if schema validation falls back to
|
|
52
|
+
a permissive schema. Rename the argument to something like `request_context`.
|
|
53
|
+
Nested object properties named `server_context` are safe and remain supported.
|
|
54
|
+
|
|
44
55
|
```ruby
|
|
45
56
|
# Symbol keys (standard Ruby)
|
|
46
57
|
ClaudeAgentSDK.create_tool('save', 'Save a fact', {
|
|
@@ -92,6 +103,12 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
92
103
|
)
|
|
93
104
|
```
|
|
94
105
|
|
|
106
|
+
An exception raised inside a handler is returned to the model as an
|
|
107
|
+
`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.
|
|
111
|
+
|
|
95
112
|
## Mixed Server Support
|
|
96
113
|
|
|
97
114
|
You can use both SDK and external MCP servers together:
|
|
@@ -108,6 +125,11 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
108
125
|
)
|
|
109
126
|
```
|
|
110
127
|
|
|
128
|
+
A hand-written SDK entry may use String or Symbol keys:
|
|
129
|
+
`{ 'type' => 'sdk', 'name' => 'calc', 'instance' => server }` behaves exactly like
|
|
130
|
+
the Hash `create_sdk_mcp_server` returns. The instance is registered in-process
|
|
131
|
+
and is never sent to the CLI.
|
|
132
|
+
|
|
111
133
|
## MCP Resources and Prompts
|
|
112
134
|
|
|
113
135
|
SDK MCP servers can also expose **resources** (data sources) and **prompts** (reusable templates):
|
data/docs/observability.md
CHANGED
|
@@ -6,6 +6,8 @@ The SDK includes a built-in **observer interface** and an **OpenTelemetry observ
|
|
|
6
6
|
|
|
7
7
|
When `connect` spawns the CLI and there is an active OTel span, the SDK injects `TRACEPARENT`/`TRACESTATE` (and any other propagator carrier keys, e.g. `BAGGAGE` — which may carry user-defined key/values — uppercased) into the subprocess environment so CLI-side telemetry (`CLAUDE_CODE_ENABLE_TELEMETRY=1`) joins the caller's distributed trace. This requires the `opentelemetry` gem to be loaded with a configured propagator — there is no hard dependency, and it is a no-op otherwise. Explicit `ClaudeAgentOptions#env` keys always win; stale inherited `TRACEPARENT`/`TRACESTATE` is replaced (or unset) only when an active span supersedes it. This works independently of `OTelObserver`: the CLI parents under the caller's surrounding span, not under `claude_agent.session` (which starts at InitMessage, after spawn).
|
|
8
8
|
|
|
9
|
+
The SDK also carries the active OTel context (including baggage) into the reactor fibers created by `query()` and standalone `Client.open`, their background input/control tasks, and callback worker-thread hops. `OTelObserver` session spans therefore keep the surrounding caller span as their parent in both default `:thread` and opt-in `:inline` mode. For `Client`, keep the intended parent active while connecting and receiving messages: background control callbacks inherit the connection's context, while message observers use the receiving operation's context. Context is captured per operation/task, not when an observer is constructed, so sequential observer reuse does not retain an earlier caller's parent. This uses OTel's scoped context API only when loaded; it does not copy application thread-local state or change callback scheduling.
|
|
10
|
+
|
|
9
11
|
## How It Works
|
|
10
12
|
|
|
11
13
|
Register observers via `ClaudeAgentOptions`. The SDK calls `on_user_prompt` when a prompt is sent — the verbatim string for String prompts (`query()` / `Client#query`), and once per `type: 'user'` message with extractable text for Enumerator/streaming input (`query()` stream path and `Client#connect` with an initial enumerable). It calls `on_message` for every parsed message, `on_error` once per error that surfaces to your code (before `on_close` where both fire), and `on_close` when the session ends. Observer errors are silently rescued so they never crash your application.
|
|
@@ -116,6 +118,10 @@ The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInf
|
|
|
116
118
|
| `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `output.value` |
|
|
117
119
|
| `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` |
|
|
118
120
|
|
|
121
|
+
`gen_ai.usage.cost` and `llm.cost.total` record the **increase** since the last observed `ResultMessage.total_cost_usd` in the connected session, rather than repeating that cumulative total on every turn's span. The baseline survives per-turn span resets and resets on close, a changed session ID (such as `/clear`), or a decreased counter. The original `ResultMessage` is unchanged.
|
|
122
|
+
|
|
123
|
+
Missing costs omit both attributes without discarding the last known total; the next reported increment can therefore include an unreported or interrupted turn. The first observation uses the CLI's reported total. If a newer CLI restores historical spend on resume, that first span also includes it: a fresh observer cannot separate spend it never observed. These are CLI cost estimates, not billing records.
|
|
124
|
+
|
|
119
125
|
Events (`api_retry`, `rate_limit`, `tool_progress`) are recorded on the root span.
|
|
120
126
|
|
|
121
127
|
The `langfuse.observation.type` attribute is set on each span (`agent`/`generation`/`tool`) to enable Langfuse's **trace flow diagram** (DAG graph visualization).
|
data/docs/rails.md
CHANGED
|
@@ -67,7 +67,9 @@ With `:inline`, every user callback — message blocks, hooks, permission callba
|
|
|
67
67
|
- No per-call threads exist, so nothing can strand an AR connection.
|
|
68
68
|
- The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware.
|
|
69
69
|
- 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.
|
|
70
|
+
- A cooperative deadline bounds the cancellation *request*, not the callback's completion: the cancellation is delivered once, and whatever the callback's `ensure` does afterwards runs unbounded on the reactor fiber. Fiber-aware cleanup delays only that callback (and the CLI waiting on its reply); scheduler-opaque cleanup — a file `fsync`, a non-fiber-aware driver, a GVL-holding C extension — stalls every job on the reactor for as long as it takes, and no deadline can interrupt it. Keep inline cleanup fiber-aware, or stay on `:thread` scheduling when you need a hard bound on the whole invocation.
|
|
70
71
|
- The CLI's cancellation of an in-flight callback (e.g. permission prompt superseded) can now actually interrupt it at a suspension point.
|
|
72
|
+
- Calling `client.disconnect` from inside an inline **control-request callback** (a hook, `can_use_tool`, or an SDK MCP handler) works, with one difference from `:thread` mode: the callback's own task is a child of the read task that `disconnect` stops, so after the teardown has completed (transport closed, pending control waiters released) the deferred `Async::Stop` unwinds the callback — `disconnect` raises there instead of returning. Put cleanup in `ensure`; a `rescue StandardError` will not see it (it is not a `StandardError`). In `:thread` mode `disconnect` returns normally on the worker thread and the callback's return value is simply dropped. Either way the callback's response is never sent — the session is gone. Message blocks and observers run on the caller's task, not under the read task, so a `disconnect` from one of those returns normally in both modes; a streaming-input enumerator that calls `disconnect` unwinds with `Async::Stop` in both modes.
|
|
71
73
|
|
|
72
74
|
The one real risk: **scheduler-opaque blocking stalls the whole reactor.** CPU-bound work or a GVL-holding C extension inside an inline callback blocks every job on that worker, not just yours. Blocking that releases the GVL and pure-Ruby CPU work can be moved onto a thread explicitly:
|
|
73
75
|
|
data/docs/sessions.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. These APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees.
|
|
4
4
|
|
|
5
|
-
Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution.
|
|
5
|
+
Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike.
|
|
6
6
|
|
|
7
7
|
## Listing Sessions
|
|
8
8
|
|
|
@@ -25,6 +25,8 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true)
|
|
|
25
25
|
|
|
26
26
|
Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`.
|
|
27
27
|
|
|
28
|
+
Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths (a blank `cwd` falls back to the project path).
|
|
29
|
+
|
|
28
30
|
## Reading Session Messages
|
|
29
31
|
|
|
30
32
|
```ruby
|
|
@@ -125,7 +127,7 @@ ClaudeAgentSDK.fork_session(
|
|
|
125
127
|
)
|
|
126
128
|
```
|
|
127
129
|
|
|
128
|
-
> Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session`
|
|
130
|
+
> Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched.
|
|
129
131
|
|
|
130
132
|
## Resuming at a Specific Message
|
|
131
133
|
|
|
@@ -210,18 +212,45 @@ ClaudeAgentSDK.query(
|
|
|
210
212
|
```
|
|
211
213
|
|
|
212
214
|
Relevant options: `session_store`, `session_store_flush` (`"batched"` default, or
|
|
213
|
-
`"eager"` to flush
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
215
|
+
`"eager"` to flush each frame as soon as the store is free — frames arriving
|
|
216
|
+
while an append is in flight are coalesced into the next append, so a slow
|
|
217
|
+
store never accumulates one background task per frame), and `load_timeout_ms`
|
|
218
|
+
(per store call during resume materialization, default `60_000`).
|
|
219
|
+
|
|
220
|
+
Resume materialization re-serializes each loaded entry to JSONL. An entry that
|
|
221
|
+
cannot be serialized (NaN/Infinity, invalid UTF-8, circular nesting), an
|
|
222
|
+
unserializable subagent metadata sidecar, or a subkey that is not a safe
|
|
223
|
+
relative String path is skipped with a warning on stderr (naming the entry's
|
|
224
|
+
`uuid` when it has one) rather than aborting the resume. A session left with
|
|
225
|
+
no usable entries is treated like an empty one: `resume:` falls through to the
|
|
226
|
+
normal spawn path and `continue_conversation` moves on to the next candidate.
|
|
227
|
+
|
|
228
|
+
> **Store-backed resume runs against a temp `CLAUDE_CONFIG_DIR`.** The SDK
|
|
229
|
+
> materializes the session transcript (plus subagent transcripts, when the
|
|
230
|
+
> store implements `#list_subkeys`) into it and seeds it from your real config
|
|
231
|
+
> dir (`CLAUDE_CONFIG_DIR` from `options.env`/`ENV`, else `~/.claude`):
|
|
232
|
+
>
|
|
233
|
+
> - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed
|
|
234
|
+
> subprocess can't consume it. On macOS with the default config dir and no
|
|
235
|
+
> `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from
|
|
236
|
+
> the Keychain entry when one exists (the redirected config dir would
|
|
237
|
+
> otherwise miss it).
|
|
238
|
+
> - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else
|
|
239
|
+
> `~/.claude.json`).
|
|
240
|
+
> - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`,
|
|
241
|
+
> hooks and `permissions` still apply — minus `enabledPlugins`,
|
|
242
|
+
> `extraKnownMarketplaces` and `env.CLAUDE_CONFIG_DIR`, which would misbehave
|
|
243
|
+
> under the redirected config dir (plugin declarations would re-install every
|
|
244
|
+
> declared marketplace on each resume).
|
|
245
|
+
>
|
|
246
|
+
> Everything else in your config dir is **not** visible to the subprocess —
|
|
247
|
+
> notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the
|
|
248
|
+
> plugin keys stripped, user plugins are off) — so a store-backed resume can
|
|
249
|
+
> still behave differently from a plain `resume:` of the same session.
|
|
250
|
+
> Project-level `.claude/*` still applies (it resolves from `cwd`), and
|
|
251
|
+
> hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected.
|
|
252
|
+
> Seeded files are written owner-only (`0600`); a missing source file is simply
|
|
253
|
+
> skipped.
|
|
225
254
|
>
|
|
226
255
|
> The temp dir is deleted at disconnect — **unless the mirror dropped batches**
|
|
227
256
|
> (terminal append failures — timeouts immediately, other failures after up to
|
|
@@ -235,6 +264,13 @@ during resume materialization, default `60_000`).
|
|
|
235
264
|
Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and
|
|
236
265
|
`#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and
|
|
237
266
|
`#list_session_summaries` are optional and probed via `SessionStore.implements?`.
|
|
267
|
+
Report `mtime` as epoch milliseconds; the SDK also orders numeric-string and
|
|
268
|
+
ISO-8601-string mtimes correctly, but anything else sorts as oldest. Subagent
|
|
269
|
+
transcripts arrive under a `subpath` key such as `subagents/agent-<agent_id>`
|
|
270
|
+
(or nested `subagents/workflows/<runId>/agent-<agent_id>`); on a store without
|
|
271
|
+
`#list_subkeys` the subagent readers build `subagents/agent-<agent_id>` from the
|
|
272
|
+
caller's `agent_id`, which the SDK first restricts to `[A-Za-z0-9._-]+`
|
|
273
|
+
(never `.`/`..`), so a path- or prefix-keyed adapter cannot be re-routed by it.
|
|
238
274
|
Validate your adapter with the shipped, framework-agnostic conformance harness:
|
|
239
275
|
|
|
240
276
|
```ruby
|
|
@@ -282,6 +318,16 @@ Declaring `:inline` means the calls run in place on the reactor fiber under a
|
|
|
282
318
|
The drop is surfaced like every dropped batch — `MirrorErrorMessage` on
|
|
283
319
|
the stream, `batches_dropped?` on the batcher — and the local transcript
|
|
284
320
|
remains the source of truth, so nothing is lost from the session itself.
|
|
321
|
+
- The timeout bounds the cancellation **request**, not the call's
|
|
322
|
+
completion: the cancellation is delivered once, at the next suspension
|
|
323
|
+
point, and the adapter's `ensure` / rescue cleanup then runs unbounded on
|
|
324
|
+
the reactor fiber before the timeout is reported. Fiber-aware cleanup
|
|
325
|
+
(closing an async client, releasing an async lock) delays only that call;
|
|
326
|
+
scheduler-opaque cleanup — an `fsync`, a non-fiber-aware driver's
|
|
327
|
+
disconnect, a GVL-holding C extension — stalls the whole reactor for its
|
|
328
|
+
duration, and no deadline can interrupt it. Keep inline cleanup
|
|
329
|
+
fiber-aware, or leave the adapter on the default thread hop when a hard
|
|
330
|
+
bound on the whole call matters more than fiber affinity.
|
|
285
331
|
|
|
286
332
|
Anything other than `:thread`/`:inline` raises `ArgumentError` when the
|
|
287
333
|
session is set up; without a reactor the hard thread-hop bound still applies
|
|
@@ -308,7 +354,12 @@ The browsing/mutation helpers above have store-backed counterparts that take a
|
|
|
308
354
|
project keys (parity with the Python SDK).
|
|
309
355
|
- Mutations: `rename_session_via_store`, `tag_session_via_store`,
|
|
310
356
|
`delete_session_via_store` (a no-op on append-only stores without `#delete`),
|
|
311
|
-
`fork_session_via_store`.
|
|
357
|
+
`fork_session_via_store`. Like their disk counterparts, rename, tag, and fork
|
|
358
|
+
raise `Errno::ENOENT` for a session the store has never seen (`#load`
|
|
359
|
+
returns nil or `[]`) instead of appending to — and so creating — a phantom
|
|
360
|
+
session. Rename/tag probe with one `#load` before appending; the probe is
|
|
361
|
+
check-then-act, so a session deleted concurrently between the probe and the
|
|
362
|
+
append can still be recreated by that append.
|
|
312
363
|
- Migration: `import_session_to_store` replays a local on-disk session (and its
|
|
313
364
|
subagents) into a store.
|
|
314
365
|
|
|
@@ -26,7 +26,9 @@ module ClaudeAgentSDK
|
|
|
26
26
|
# Stdlib only (net/http, json, digest, fileutils, rbconfig) — the gem gains
|
|
27
27
|
# no runtime dependency for this.
|
|
28
28
|
#
|
|
29
|
-
# @example
|
|
29
|
+
# @example Install the gem's tested version in bin/setup or a Dockerfile build step
|
|
30
|
+
# ClaudeAgentSDK::CLIInstaller.install_pinned
|
|
31
|
+
# @example Pin a version of your own
|
|
30
32
|
# ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
|
|
31
33
|
module CLIInstaller
|
|
32
34
|
BASE_URL = 'https://downloads.claude.ai/claude-code-releases'
|
|
@@ -39,6 +41,13 @@ module ClaudeAgentSDK
|
|
|
39
41
|
# path — silently installing something other than the pinned version.
|
|
40
42
|
VERSION_PATTERN = /\A\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\z/
|
|
41
43
|
CHECKSUM_PATTERN = /\A[0-9a-f]{64}\z/
|
|
44
|
+
# The CLI version this gem release is developed and tested against — the
|
|
45
|
+
# Ruby equivalent of the Python SDK's bundled-CLI pin (_cli_version.py),
|
|
46
|
+
# except nothing is shipped inside the gem; +install_pinned+ downloads it.
|
|
47
|
+
# Single source of truth: bumped here (and only here) by
|
|
48
|
+
# .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
|
|
49
|
+
# Dependabot bump of the gem carries the CLI forward with it.
|
|
50
|
+
PINNED_CLI_VERSION = '2.1.280'
|
|
42
51
|
BINARY_NAME = 'claude'
|
|
43
52
|
VERSION_FILE = 'VERSION'
|
|
44
53
|
LOCK_FILE = '.install.lock'
|
|
@@ -263,34 +272,33 @@ module ClaudeAgentSDK
|
|
|
263
272
|
end
|
|
264
273
|
end
|
|
265
274
|
|
|
266
|
-
# The VERSION file:
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
# An older single-line VERSION file simply reads as "no metadata", which
|
|
271
|
-
# triggers a clean reinstall.
|
|
275
|
+
# The VERSION file: version, verified SHA-256, and target platform, one
|
|
276
|
+
# per line. Both platform and checksum must match before trusting a cached
|
|
277
|
+
# binary offline: a cache copied between OS/CPU/libc targets is not usable.
|
|
278
|
+
# Older one- or two-line files lack that proof and trigger a clean reinstall.
|
|
272
279
|
module Metadata
|
|
273
280
|
class << self
|
|
274
281
|
def read(dir)
|
|
275
282
|
path = File.join(dir, VERSION_FILE)
|
|
276
283
|
return nil unless File.file?(path)
|
|
277
284
|
|
|
278
|
-
version, checksum = File.read(path, METADATA_READ_LIMIT).to_s.split("\n",
|
|
285
|
+
version, checksum, platform = File.read(path, METADATA_READ_LIMIT).to_s.split("\n", 4)
|
|
279
286
|
version = version.to_s.strip
|
|
280
287
|
checksum = checksum.to_s.strip.downcase
|
|
281
|
-
|
|
288
|
+
platform = platform.to_s.strip
|
|
289
|
+
return nil unless version.match?(VERSION_PATTERN) && checksum.match?(CHECKSUM_PATTERN) && !platform.empty?
|
|
282
290
|
|
|
283
|
-
{ version: version, checksum: checksum }
|
|
291
|
+
{ version: version, checksum: checksum, platform: platform }
|
|
284
292
|
end
|
|
285
293
|
|
|
286
294
|
# Atomic: an unpredictable temp name opened O_EXCL, then renamed over
|
|
287
295
|
# the old file. Without this a reader could observe a half-written
|
|
288
296
|
# VERSION, or (worse) the previous version paired with a new binary.
|
|
289
|
-
def write(dir, version, checksum)
|
|
297
|
+
def write(dir, version, checksum, platform)
|
|
290
298
|
tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp")
|
|
291
299
|
begin
|
|
292
300
|
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
293
|
-
file.write("#{version}\n#{checksum}\n")
|
|
301
|
+
file.write("#{version}\n#{checksum}\n#{platform}\n")
|
|
294
302
|
end
|
|
295
303
|
File.rename(tmp, File.join(dir, VERSION_FILE))
|
|
296
304
|
ensure
|
|
@@ -334,9 +342,9 @@ module ClaudeAgentSDK
|
|
|
334
342
|
with_install_lock(dir) do
|
|
335
343
|
sweep_stale_temp_files(dir)
|
|
336
344
|
resolved = Release.resolve_version(requested)
|
|
337
|
-
next binary if installed?(dir, resolved)
|
|
338
|
-
|
|
339
345
|
platform = Platform.detect
|
|
346
|
+
next binary if installed?(dir, resolved, platform)
|
|
347
|
+
|
|
340
348
|
publish(dir, binary, resolved, platform, Release.platform_entry(resolved, platform))
|
|
341
349
|
binary
|
|
342
350
|
end
|
|
@@ -349,6 +357,14 @@ module ClaudeAgentSDK
|
|
|
349
357
|
raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}"
|
|
350
358
|
end
|
|
351
359
|
|
|
360
|
+
# Install PINNED_CLI_VERSION — the version this gem release was tested
|
|
361
|
+
# against. The Dockerfile / bin/setup form of "pin the tested pair":
|
|
362
|
+
# bumping the gem moves the CLI with it, with no version literal in the
|
|
363
|
+
# caller to keep in sync.
|
|
364
|
+
def install_pinned(dir: nil)
|
|
365
|
+
install(version: PINNED_CLI_VERSION, dir: dir)
|
|
366
|
+
end
|
|
367
|
+
|
|
352
368
|
# Path of an already-installed binary, or nil.
|
|
353
369
|
#
|
|
354
370
|
# Deliberately lock-free, because #publish makes the lock unnecessary
|
|
@@ -400,13 +416,13 @@ module ClaudeAgentSDK
|
|
|
400
416
|
end
|
|
401
417
|
|
|
402
418
|
# True only when the vendored binary is byte-for-byte the one recorded
|
|
403
|
-
# by a previous install of this exact version. No network access.
|
|
404
|
-
def installed?(dir, version)
|
|
419
|
+
# by a previous install of this exact version and platform. No network access.
|
|
420
|
+
def installed?(dir, version, platform)
|
|
405
421
|
binary = installed_path(dir: dir)
|
|
406
422
|
return false unless binary
|
|
407
423
|
|
|
408
424
|
recorded = Metadata.read(dir)
|
|
409
|
-
return false unless recorded && recorded[:version] == version
|
|
425
|
+
return false unless recorded && recorded[:version] == version && recorded[:platform] == platform
|
|
410
426
|
|
|
411
427
|
Digest::SHA256.file(binary).hexdigest == recorded[:checksum]
|
|
412
428
|
rescue SystemCallError
|
|
@@ -434,7 +450,7 @@ module ClaudeAgentSDK
|
|
|
434
450
|
tmp = "#{binary}.download.#{SecureRandom.hex(8)}"
|
|
435
451
|
begin
|
|
436
452
|
fetch_verified(version, platform, entry, tmp)
|
|
437
|
-
Metadata.write(dir, version, entry[:checksum])
|
|
453
|
+
Metadata.write(dir, version, entry[:checksum], platform)
|
|
438
454
|
File.rename(tmp, binary)
|
|
439
455
|
ensure
|
|
440
456
|
FileUtils.rm_f(tmp)
|