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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5c70ede77de755091c7587cb3bed3ea95075ed6db34b7a2cc4e3214db9adcb5
4
- data.tar.gz: b4d77a1e0888dc0ef3e436e933ee2334d6912b087e440fb88af0765e34703e10
3
+ metadata.gz: ff96202bf91fc547d93ae77a7b022fb83a0651574fcb1aae5e521531e33ea9e7
4
+ data.tar.gz: 6956e7943d0856d9ac5975a7ba01e779159df1193301199dc0f082bf80835d88
5
5
  SHA512:
6
- metadata.gz: '0658614c047b36d2cd7c8724c01e31b1bdcb4aac81246caf6e56c7afb9e8203bf2e7cedfd112c59c317fb0cd5359785737e8a4e33efa67f448ee47792a81fc17'
7
- data.tar.gz: 2938e8804ee6396fdadb60be8345da7317628f853ec35947aba5f8542c732346d4a360aa0222f57977d18578e3689e0307d55337802f908ce4eed85543518122
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.33.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'`.
@@ -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
- # 'stable' (default), 'latest', or a concrete version — pin it in production.
11
- ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')
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
- ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220', dir: '/opt/claude')
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 **and** the SHA-256 that was verified at download time. The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when both match — a truncated, swapped or half-written binary is reinstalled instead of trusted. It makes **no network request**, so 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.
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 — pin the CLI in its own cached layer
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.install(version: '2.1.220')"
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
- puts ClaudeAgentSDK::CLIInstaller.install(version: ENV.fetch('CLAUDE_CLI_VERSION', 'stable'))
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-4-5') # Switch model mid-conversation
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
@@ -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-4-20250514',
123
- fallback_model: 'claude-3-5-haiku-20241022'
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' # alias or full model ID, e.g. 'claude-opus-4-8'
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
- | `CLIJSONDecodeError` | JSON parsing issues |
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):
@@ -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` uses `O_CREAT | O_EXCL` to prevent race conditions.
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 after every frame), and `load_timeout_ms` (per store call
214
- during resume materialization, default `60_000`).
215
-
216
- > **Store-backed resume runs against a bare temp `CLAUDE_CONFIG_DIR`.** Only the
217
- > transcript plus `.credentials.json` (redacted) and `.claude.json` are
218
- > materialized into it — user-scope `settings.json` (hooks, `permissions`),
219
- > user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` from your real config
220
- > dir are **not** visible to the subprocess, so a store-backed resume can
221
- > behave differently from a plain `resume:` of the same session. Project-level
222
- > `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
223
- > programmatically via `ClaudeAgentOptions` are unaffected. This matches the
224
- > Python and TypeScript SDKs.
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 Pin a version in bin/setup or a Dockerfile build step
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: line 1 the installed version, line 2 the SHA-256 of the
267
- # binary that was verified at install time. The checksum is what lets the
268
- # idempotency shortcut trust the vendored binary without a network call —
269
- # a truncated, swapped or half-written binary no longer looks installed.
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", 3)
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
- return nil unless version.match?(VERSION_PATTERN) && checksum.match?(CHECKSUM_PATTERN)
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)