Skip to content

v0.34.0

Choose a tag to compare

@ya-luotao ya-luotao released this 23 Sep 09:43
· 410 commits to main since this release

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 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.
  • .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.

Changed

  • 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)
  • 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)
  • 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)
  • 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)
  • 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.
  • Subagent readers only accept agent_ids 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)
  • 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)
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

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 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)
  • 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)
  • 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)
  • 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)
  • 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)
  • 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.
  • 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 stderr callback, debug_stderr and ProcessError#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/claude now ends in CLINotFoundError with install instructions instead of a raw Errno::EACCES at spawn. (#72)
  • 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)
  • Sandbox settings merging resolves a relative settings file against the CLI's cwd, not the Ruby process's working directory.
  • FiberBoundary worker threads disable report_on_exception before 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*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)
  • 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)
  • 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)
  • 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.
  • 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.
  • 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)

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 --continue classifies sidechains from the first surviving entry. (#83)
  • Non-String subkeys from a SessionStore are rejected like any other unsafe subkey instead of raising NoMethodError and aborting the resume. (#91)
  • 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)
  • 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)
  • 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_id gets the same nil / [] as a malformed id (and import_session_to_store raises ArgumentError) instead of a deep NoMethodError / ArgumentError. (#74)
  • 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)
  • 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).
  • 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.
  • 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.json included since 0.31). (#86)

Observability

  • 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.
  • 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.

Examples and tooling

  • 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)
  • 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)

Usage

# Vendor the CLI version this gem release was tested against (2.1.280)
ClaudeAgentSDK::CLIInstaller.install_pinned

What'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