Repository navigation
v0.34.0
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.
Added
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.CLIInstaller.install_pinned(dir: nil)— installs exactlyPINNED_CLI_VERSION. The Dockerfile /bin/setupform 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 explicitversion:pins behave exactly as before..github/workflows/cli-pin-bump.yml— scheduled (and manually dispatchable) workflow that reads the Python SDK's_cli_version.pyonmainand opens a PR movingPINNED_CLI_VERSIONwhen it changes. It touches only that one line; cutting the follow-up patch release stays a human decision.
Changed
- Runtime dependency floors now name versions that actually work:
async >= 2.10, < 3andmcp >= 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 withTask#defer_stop, whichQuery#closenow relies on.mcp0.20/0.21 fail everytools/callof a tool whose schema uses$refinto$defs. Only bundles pinned below these floors are affected. (#87) 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 raisesFrozenError, 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)rename_session_via_store/tag_session_via_storeraiseErrno::ENOENTfor a session the store has never seen (#loadreturns nil or[]), like their disk counterparts andfork_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)exit,Interruptand signal exceptions raised inside an SDK MCP tool handler are reported in-band as anisError: trueresult, so thetools/callcontrol response is always written; previously they escaped dispatch (and in the default:threadschedulingexittore down the session). Cancellation still propagates. Acallback_wrappernow observes aRuntimeErrorwhose#causeis the original exception. Hooks andcan_use_toolare tracked in #119. (#77)- Transcript mirror
eagermode no longer guarantees oneSessionStore#appendper 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. - Subagent readers only accept
agent_ids matching[A-Za-z0-9._-]+(never.or..); anything else returns[]/nilwithout calling the adapter, so a path- or prefix-keyedSessionStorecan't be re-routed through the synthesizedsubagents/agent-<agent_id>subpath (now documented in the adapter contract). (#79) - Blank session metadata reads the same on disk and in a store: whitespace-only
git_branch/tagareniland a blankcwdfalls back to the project path on the disk path too. (#67) - S3 reference adapter (
examples/session_stores/s3_session_store.rb) reserves each part number through a per-transcript.sequenceobject with conditional writes, so appends stay ordered across adapter instances and clock skew. Requires an S3-compatible endpoint with strong consistency and conditionalPUT; stop old writers before upgrading (mixed old/new writers are unsupported), and quiesce writers before deleting sessions. Each uncontended append costs two extra requests. CLIInstaller'sVERSIONfile 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.- SDK MCP servers reject a tool whose input schema declares a top-level
server_contextproperty at registration (themcpgem would silently overwrite it with its own context), and a call that passes one to a composed/referenced/free-form schema gets an actionableisErrorresult without invoking the handler. - The transport's "Claude Code not found" guidance,
docs/cli-installer.mdand the skill references now point atinstall_pinned(interpolating the constant) instead of a hardcoded example version that went stale with every CLI release. - Docs, examples and the bundled
claude-agent-rubyskill now use current model IDs (claude-opus-5,claude-sonnet-5,claude-haiku-4-5) and recommend adaptive thinking pluseffort:;ThinkingConfigEnabled(budget_tokens:)is described as the older-model path.examples/extended_thinking_example.rbno longer teaches the deprecatedmax_thinking_tokens. No API changes.
Fixed
Subprocess transport and teardown
- 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 leftready?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 raisesCLIConnectionError("possible partial frame"). Recovery is a new session. (#80) closeandend_inputno longer hang or strand a writer parked on a full stdin pipe. A reactor fiber parked in the write is woken with the documentedCLIConnectionError, 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)read_messagesno 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 raisesProcessError("did not exit within 5s of closing stdout", negative-signalexit_code). (#73)- A truncated final stdout frame is reported as
CLIJSONDecodeError(lineholds the partial frame) instead of the stream silently ending without itsResultMessage. Whitespace-only tails and reads cut short byclosestay silent. (#89) Client#disconnectcalled 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 cascadingAsync::Stopused to interruptQuery#closebefore the transport and the close watcher were closed (a bareQuery#closeleaked the CLI process). The stop is now deferred through the teardown; the caller then unwinds withAsync::Stop(useensure, notrescue StandardError). A teardown error superseded by that Stop is warned on stderr.:threadmode, message blocks and observers are unchanged:disconnectreturns normally there. (#81)- An outer
Asynctimeout duringcloseno 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. - 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.
- CLI stderr is scrubbed to valid UTF-8 for the
stderrcallback,debug_stderrandProcessError#stderr, like stdout frames. (#90) - CLI discovery no longer accepts a non-executable file at a well-known install location — a stray 0644
~/.claude/local/claudenow ends inCLINotFoundErrorwith install instructions instead of a rawErrno::EACCESat spawn. (#72) - Store-backed resume and CLI discovery work on hosts without a usable home directory (
HOMEunset with no passwd entry, e.g.docker --userin a minimal image, or an empty/relativeHOME): home-relative auth seeds and install locations are skipped instead of raisingArgumentError.Sessions.config_dir/projects_dirare tracked in #120. (#82) - Sandbox settings merging resolves a relative settings file against the CLI's
cwd, not the Ruby process's working directory. FiberBoundaryworker threads disablereport_on_exceptionbefore the callback runs, so a fast-failing callback is no longer also dumped to stderr. (#93)
Options and configuration
- 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*ServerConfigandSdkPluginConfigplaced inClaudeAgentSDK.configuredefaults, or copied byClaudeAgentOptions#dup_with(including values nested inagents,hooks,mcp_servers,plugins), are copied per instance via the newType#dup_for_optionshook — 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 andcallback_wrapperkeep their identity; CLI arguments are unchanged. (#69, #70) Configuration#default_optionsis 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 raisecan't add a new key into hash during iterationor tear a merge. (#92)- String-keyed SDK MCP server configs are recognized (
{ 'tools' => { 'type' => 'sdk', 'instance' => server } }, andtype: :sdk): the instance is registered in-process, stdin stays open for its tool calls, and it is never serialized into--mcp-config. (#68) Clientnormalizes input likequery(): a bare Hash prompt is rejected before anything is spawned (it used to stream Ruby inspection strings), andnil/ empty hook lists are accepted instead of crashingconnect.- Class-valued shorthand tool schemas work: the documented
{ id: Integer }advertises and accepts an integer (it advertised a string and rejected integers);Float,TrueClassandFalseClasslikewise. ResultError's message and#api_error_statusshare one Integer narrowing, so a non-Integer status (e.g."500") no longer appears asAPI error (HTTP 500)while the accessor returnsnil. (#76)
Sessions and SessionStore
- 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--continueclassifies sidechains from the first surviving entry. (#83) - Non-String subkeys from a
SessionStoreare rejected like any other unsafe subkey instead of raisingNoMethodErrorand aborting the resume. (#91) - Store-backed listings coerce adapter
mtimevalues (ISO-8601 or numeric strings, e.g. SQL timestamps through JSON): they list newest first — they came back oldest first, solimit:cut off the newest sessions — and mixed Integer/String mtimes no longer raise. (#66) - Session listings break equal-mtime ties by
session_id, sooffset:/limit:pages are stable across calls and never skip or repeat sessions, and disk and store listings order identical input identically. (#78) - 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)
- Session read APIs validate ids at the boundary: a non-String or invalidly encoded
session_id/agent_idgets the samenil/[]as a malformed id (andimport_session_to_storeraisesArgumentError) instead of a deepNoMethodError/ArgumentError. (#74) - The transcript mirror no longer piles up background tasks when the
SessionStoreis 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) - Rename and tag no longer corrupt a transcript whose last record lacks a trailing newline: the metadata record is written on its own line.
- 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).
- 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).
InMemorySessionStoredeep-copies entries and summaries acrossappend/load/ summary boundaries, so callers can no longer mutate stored transcripts through the objects they passed in or got back.- 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.
- Docs and the bundled skill describe what store-backed resume really seeds (
settings.json/cowork_settings.jsonincluded since 0.31). (#86)
Observability
OTelObserverrecords per-turn cost increments forgen_ai.usage.cost/llm.cost.total; the CLI reports cumulativetotal_cost_usd, so summing spans used to double-count earlier turns.- OpenTelemetry parent context is preserved across the SDK's fibers and threads (
query()/Client.openreactors, Query's background tasks, FiberBoundary worker dispatch), so observer and callback spans attach to the caller's trace instead of starting detached.
Examples and tooling
examples/error_handling_example.rb's retry helper now retries what the SDK raises:ResultErroronly for transient API statuses (429, 529, other 5xx), then bareProcessErrorwith bounded backoff. (#88)- 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.mdlistsResultError; flaky ordering sleeps in specs are gone. (#87, #93)
Usage
# Vendor the CLI version this gem release was tested against (2.1.280)
ClaudeAgentSDK::CLIInstaller.install_pinnedWhat's changed
- #57 Pin the tested CLI version (2.1.280) and expose install_pinned — @ya-luotao
- #65 Refresh model IDs and thinking guidance in docs, examples and skill — @ya-luotao
- #94 Fix sandbox settings merge relative to CLI cwd (audit 2) — @ya-luotao
- #95 Fix session mutation record boundaries (audit problem 5) — @ya-luotao
- #96 Fix audit 1: retain subprocess cleanup on outer Async timeout — @ya-luotao
- #97 Fix summary conformance content checks (audit problem 12) — @ya-luotao
- #98 Fix Ruby class-valued tool input schemas (audit 9) — @ya-luotao
- #99 Publish complete session forks atomically (audit problem 6) — @ya-luotao
- #100 Fix audit 3: terminate control waiters on clean EOF — @ya-luotao
- #101 Fix in-memory session store JSON isolation (audit problem 15) — @ya-luotao
- #102 Include platform in CLI installer cache identity (audit 8) — @ya-luotao
- #103 Honor cleared titles in long transcripts (audit problem 14) — @ya-luotao
- #104 Align Client input normalization with query (audit 13) — @ya-luotao
- #105 Fix audit 4: bound control sends and clean failed write registrations — @ya-luotao
- #106 Reject reserved MCP tool argument at registration (audit 16) — @ya-luotao
- #107 Fix durable S3 transcript append ordering (audit problem 7) — @ya-luotao
- #108 Avoid double-counting cumulative OTel cost (audit 10) — @ya-luotao
- #109 Fix audit 17: release reaped fallback processes and join test workers — @ya-luotao
- #110 Preserve OTel parent context across fibers and threads (audit 11) — @ya-luotao
- #111 Harden CLI discovery, HOME-less resume and stderr encoding (#72, #82, #90) — @ya-luotao
- #112 Tolerate poisoned store entries on resume; stop phantom store sessions (#83, #85, #91) — @ya-luotao
- #113 Copy typed option values per session; snapshot configured defaults (#69, #70, #92) — @ya-luotao
- #114 Recognize string-keyed SDK MCP configs; report handler exits in-band (#68, #76, #77) — @ya-luotao
- #115 Coalesce transcript mirror drains behind one background task (#84) — @ya-luotao
- #116 Make session listings and reads agree across disk and store paths (#66, #67, #74, #75, #78, #79) — @ya-luotao
- #117 Finish teardown when disconnect is called from inside a stopped task (#81, #71) — @ya-luotao
- #118 Dependency CI matrix, working dependency floors, docs and hygiene (#86, #87, #88, #93) — @ya-luotao
- #122 Poison the transport on a cancelled write; bound the post-EOF wait (#80, #73, #89) — @ya-luotao
- #123 CHANGELOG: September 2026 audit campaign — @ya-luotao
Full Changelog: v0.33.1...v0.34.0
gem install claude-agent-sdk -v 0.34.0