Skip to content

v0.18.0

Choose a tag to compare

@ya-luotao ya-luotao released this 12 Jun 17:36
· 621 commits to main since this release

Full-SDK audit release (2026-06-12): six batches of correctness, Python-parity, and robustness fixes spanning teardown/liveness, the control protocol, observer/OTel instrumentation, the SDK MCP server, session readers, and the subprocess transport. 987 specs green.

Added

  • query(transport:) — inject a pre-constructed custom transport instance (Python parity): CLI discovery, version check, and resume materialization are skipped; the SDK calls #connect/#close on the instance (including after a failed connect — a documented safety deviation from Python).
  • Client#query now accepts an Enumerable of message Hashes / JSONL Strings in addition to a String (Python parity): items stream inline on the caller, session_id is stamped onto Hashes that lack one (explicit values, even nil, preserved), and non-Hash/String items raise instead of being silently serialized. Note: JSONL String items pass through verbatim and carry their own session_id — generate them with the matching session_id: argument (Streaming.user_message defaults to 'default').
  • Client.open(prompt = nil, options:, ...) { |client| } — block-scoped lifecycle mirroring Python's async with ClaudeSDKClient(): connects, yields, always disconnects; works standalone (creates a reactor via Sync) and inside Async blocks; returns the block's value.
  • ClaudeAgentOptions#user is now applied — the CLI subprocess is spawned as that OS user via spawn's :uid (String username or Integer uid, Unix; previously accepted but silently ignored). On unsupported platforms the failure is loud (CLIConnectionError), not silent.
  • HookMatcher#timeout is now sent to the CLI in the initialize request (per matcher, seconds — Python wire parity); the SDK-side enforcement remains as defense-in-depth.
  • ClaudeAgentSDK.list_subagents / ClaudeAgentSDK.get_subagent_messages — local-disk subagent transcript readers (disk counterparts to the existing *_from_store variants; Python SDK parity, upstream #825). Scans <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl including nested workflows/<runId>/ paths. Note: limit: 0 returns [] per the Ruby read-API family convention (Python returns all).
  • W3C trace-context propagation to the CLI subprocess (Python SDK #821 parity): when opentelemetry is loaded and a span is active at connect, TRACEPARENT/TRACESTATE (and any other propagator carrier keys such as BAGGAGE, uppercased) are injected into the CLI env so CLI-side OTel spans join the caller's distributed trace. Explicit ClaudeAgentOptions#env keys win; stale inherited W3C env is scrubbed only when an active span replaces it. No new dependency — no-op without the opentelemetry gem.
  • CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK env var skips the CLI version check (any non-empty value, Python parity), and the check itself now has a 2-second deadline — a hung claude -v (NFS-mounted binary, wedged Node bootstrap) no longer hangs connect forever. The unsupported-version warning now includes the CLI path.
  • ClaudeAgentOptions#skills (Python parity): enable skills for the main session with 'all' or an Array of names. Injects Skill/Skill(name) into --allowedTools, defaults setting_sources to ['user', 'project'] when unset, and sends explicit lists via the initialize control request so the CLI filters which skills load. [] hides all skills; this is a context filter, not a sandbox.
  • OTel spans now report prompt-cache usage: gen_ai.usage.cache_creation_input_tokens / gen_ai.usage.cache_read_input_tokens on generation and session spans, plus OpenInference llm.token_count.prompt_details.cache_read/.cache_write on the session span. Anthropic's input_tokens excludes cached tokens, so heavily cached sessions previously under-reported prompt volume by orders of magnitude. Strictly additive — existing keys unchanged.
  • on_user_prompt observers now fire for Enumerator/streaming-input prompts (once per type: 'user' message with extractable text). OTel traces for streaming sessions now get an input.value for the first trace; later turns' capture depends on prompt timing relative to each init (OTelObserver keeps one prompt per trace).

Fixed

  • SubprocessCLITransport#end_input now takes the stdin mutex like write/close (its lock-free close could race a concurrent writer into a misleading "undefined method ... for nil" error that also poisoned @exit_error).
  • CLAUDE_CODE_ENTRYPOINT now defaults to sdk-rb regardless of inherited process env (an ambient cli value from running inside a Claude Code terminal previously won via ||=, mis-attributing telemetry); options.env can still override it. CLAUDE_AGENT_SDK_VERSION is now always SDK-set, never overridable (Python merge-order parity).
  • Unknown hook events now arrive as UnknownHookInput carrying the wire hook_event_name and the complete raw payload (previously all event-specific fields were dropped and the name was nil; Python passes the raw dict through, losing nothing).
  • Oversized CLI stdout lines no longer allocate unbounded memory: the read loop's 1MB buffer cap previously fired only AFTER each_line had read the whole line into memory; reads are now chunk-bounded at max_buffer_size + 1 bytes (Python's TextReceiveStream reads ≤64KB chunks — same incremental-cap semantics). The same bound applies to the stderr drain loops.
  • advisor_tool_result content blocks now parse into ServerToolResultBlock (they previously fell through to UnknownBlock); the server_tool_result wire type was dead code — no CLI version emits it — and now takes the forward-compat UnknownBlock path.
  • README's Client quick-start example used receive_messages with no termination and hung forever when pasted; it now uses receive_response.
  • Configuration#default_options containers are now deep-duplicated when constructing ClaudeAgentOptions: options.allowed_tools << 'Bash' in one session no longer mutates the global default (cross-session permission widening) and nested default hashes/arrays no longer leak mutations. Leaf objects (callbacks, observer factories, SDK MCP server instances) keep identity.
  • Hash-form thinking config ({ type: 'adaptive'|'enabled'|'disabled', budget_tokens:, display: }) is now serialized to the CLI; it was previously dropped silently and also suppressed the max_thinking_tokens fallback. Invalid shapes raise a clear ArgumentError.
  • Control-protocol client methods (interrupt, set_model, …) can now be called from inside hook/can_use_tool/SDK-MCP callbacks (Python reentrancy parity): the call previously wrote the request to the CLI and then crashed with an opaque RuntimeError: No async task available!, silently dropping the response. Worker-thread callers now wait on a level-triggered queue with the same timeout semantics.
  • Closed a lost-wakeup race in control-request waiting: Async::Condition is edge-triggered, so a control_response arriving while the sender was still suspended in a transport write was dropped and the caller waited the full 1200s timeout (reachable with custom transports whose #write suspends after delivery, and via the read-loop error broadcast). Senders now check the result slot before and between waits (anyio.Event level-trigger semantics, like Python).
  • SdkMcpServer#handle_json now actually serves resources and prompts: resources/list crashed inside the mcp gem (Class#to_h), resources/read silently returned {contents: []} for every URI (the read path referenced MCP::ResourceContents, a constant that has never existed in any mcp gem version), prompts/list mangled names (codeReview → code_review) and dropped descriptions/arguments, and prompts/get could not find any prompt. Resources are now served as MCP::Resource instances with the gem's registered read handler; prompts via MCP::Prompt.define. Both delegate to the SDK's own readers, preserving the FiberBoundary hop and result validation.
  • Pre-built JSON Schemas with symbol values (type: :object) are no longer silently mangled into garbage (schema meta-keys leaked as parameters, every valid tools/call was then rejected with "Missing required arguments: type, properties, required"). Schema normalization is now a single source of truth; additionalProperties/enum/description survive, and a malformed pre-built schema raises a clear error lazily instead of producing silent garbage.
  • list_sessions, get_session_info, and get_session_messages no longer raise Errno::ENOENT for a directory: that does not exist — they return []/nil, matching the Python SDK (a fresh checkout or a deleted project directory previously crashed; sessions recorded for a since-deleted directory are now found again).
  • get_session_messages and import_session_to_store no longer stop at a 0-byte transcript stub: the session-file search skips empty files and continues to worktree project dirs, matching Python's st_size > 0 resolver (a stub in the canonical project dir previously hid the real worktree transcript).
  • An empty CLAUDE_CONFIG_DIR is now treated as unset (falling back to ~/.claude, NFC-normalized) across all session read/mutation paths and SessionStore mirroring, matching the Node CLI and Python SDK — previously the projects dir resolved to /projects and transcript mirroring was silently disabled.
  • list_sessions / get_session_info no longer return created_at: nil when a transcript's first JSONL record is a metadata-only entry (e.g. permission-mode) with no timestamp field — the whole 64 KiB head window is now scanned for the first timestamp (Python #907 parity). The store-backed readers already folded every entry, so the disk and store paths now agree on created_at.
  • Observer#on_error is now actually invoked — once per error surfacing from query() or Client#query/#receive_messages/#receive_response/#connect, before on_close where both fire. Crashed sessions now produce OTel traces with error status and a recorded exception.
  • OTelObserver no longer mislabels traces when one instance is reused: the buffered prompt/output are reset at every trace boundary (previously every trace after the first showed the first query's prompt as its input.value, and a nil-result trace could leak the previous query's output).
  • OTelObserver no longer leaks unfinished (never-exported) spans: a new InitMessage without an intervening ResultMessage finishes the superseded root span, and tool spans still pending at a ResultMessage or superseded init are finished at that boundary instead of waiting for on_close.
  • OTel tool spans now serialize Array tool-result content as JSON with output.mime_type: application/json (previously Ruby inspect format); String results gain output.mime_type: text/plain; nil results omit output.value (previously empty string).
  • break inside the user block of query(), Client#receive_messages and Client#receive_response now stops iteration (returning the break value) instead of raising LocalJumpError — the FiberBoundary thread hop translates the break back to the calling fiber (new internal FiberBoundary.invoke_iteration).
  • CLI stdout/stderr are now always decoded as UTF-8: under LANG=C/LC_ALL=C (minimal Docker images, systemd, CI) the pipes inherited US-ASCII and the first non-ASCII byte from the CLI killed the read loop with Encoding::CompatibilityError. Mirrors the Python SDK's UTF-8 TextReceiveStream.
  • The unsupported-CLI-version warning now actually fires: Array#< does not exist, so the version comparison raised NoMethodError into the version check's blanket rescue and the warning had never been emitted; the -v output is also UTF-8-scrubbed so a stray invalid byte cannot suppress it.
  • query() can no longer hang forever when the CLI dies while a streaming-input Enumerator is blocked: the input stream task is tracked on the Query (new Query#spawn_task, mirroring the Python SDK's _child_tasks) and stopped by close, so the real error now propagates to the caller instead of decaying to an async console warning.
  • A CLI crash now always unblocks in-flight control requests (interrupt, set_model, …) immediately instead of leaving them to the 1200s control-request timeout, and a real crash in a multi-turn session is reported to consumers instead of being silently swallowed after the first result.
  • Hooks and SDK MCP servers no longer silently stop working when a one-shot query()'s first turn runs past 60 seconds: stdin stays open (without timeout) until the first result, mirroring Python SDK commit c3d96cb. String-prompt queries also now stream messages to the block while that wait is pending instead of deferring delivery.

Changed

  • Client#connect(enumerable) now streams the initial prompt in the background (Python parity): connect returns immediately instead of blocking until the stream is exhausted (interactive streams that wait for a response before yielding no longer deadlock), Hash messages are serialized as JSON (previously Ruby inspect via to_s — never valid wire format), stdin closes when the stream is exhausted, and stream errors fire Observer#on_error and are logged instead of raising out of connect.
  • SDK MCP tools/call now routes through the official MCP::Server: arguments are JSON-Schema-validated (draft4) against the tool's inputSchema before the handler runs (Python parity — its mcp lowlevel server does the same), and validation failures return in-band isError results ("Missing required arguments: …"/"Invalid arguments: …") without invoking the handler. The mcp dependency floor rises to >= 0.6, < 1 (0.4 turned these into protocol errors; 0.5 serializes empty icons arrays into list responses). The SDK normalizes tools/call error envelopes itself, so the gem's per-version error-behavior swings (0.7.1+/0.18 raise protocol errors again) never leak through. Error texts on this path now come from the gem (e.g. "Tool not found: X", "Internal error calling tool X: msg"). Validation can be disabled globally via MCP.configure { |c| c.validate_tool_call_arguments = false }. Schemas the draft4 metaschema rejects (numeric exclusiveMinimum, $ref — valid modern JSON Schema that Python accepts) fall back to validation-disabled with a one-time warning instead of bricking the tool.
  • SDK MCP tool failures are now reported in-band (isError: true with the error text in content) instead of as JSON-RPC -32603 protocol errors — matching the MCP spec, the Python SDK, and the official mcp gem. The model can now read the error text and self-correct. This covers handler exceptions, unknown tools, and malformed handler results; SdkMcpServer#call_tool no longer raises for these cases.
  • SdkMcpServer#call_tool/#read_resource/#get_prompt now accept string-keyed handler results (Python handlers return string-keyed dicts naturally); previously they rejected them with an error the model saw as a protocol failure.
  • When an explicit directory: is given, get_session_messages and import_session_to_store no longer fall back to scanning all project directories: a session that only exists in an unrelated project now returns [] / raises Errno::ENOENT instead of silently returning/importing another project's data under the wrong project key (Python parity; directory: nil still searches all projects). A 0-byte-stub-only session now raises Errno::ENOENT from import instead of silently importing zero entries.
  • can_use_tool callbacks now receive fully populated ToolPermissionContexts: the CLI display fields (title, display_name, description, blocked_path, decision_reason) are forwarded (previously always nil), and suggestions are typed PermissionUpdate objects instead of raw wire hashes (Python #920 parity) — PermissionUpdate.new also hydrates wire-format rule hashes into PermissionRuleValue. Code treating suggestion entries as plain Hashes (dig, fetch) must use the typed accessors; echoing context.suggestions into updated_permissions keeps working.
  • A ProcessError that directly follows a result with is_error: true (the CLI exits non-zero on purpose, e.g. structured-output errors) is now raised with the structured error text the CLI reported (Claude Code returned an error result: …, preserving exit_code/stderr) instead of ending the stream silently — matching the Python SDK.
  • CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is now a no-op (the internal Query::STREAM_CLOSE_TIMEOUT_* constants were removed with the stdin-close timeout).

Removed

  • Breaking: ClaudeAgentOptions#append_allowed_tools. It emitted --append-allowed-tools, which no Claude Code CLI version accepts (error: unknown option), so any use failed at connect. The option never existed in the Python SDK (mis-port in v0.4.0). Use allowed_tools instead — the CLI's --allowedTools already appends to settings-derived permission rules. Passing append_allowed_tools: now raises ArgumentError at construction.

Usage

# Block-scoped client lifecycle (Python's `async with` equivalent)
ClaudeAgentSDK::Client.open(options: options) do |client|
  client.query('Refactor the parser')
  client.receive_response { |msg| handle(msg) }
end

# Enable skills for the main session (context filter)
ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[code-review])

# Inject a custom transport into one-shot queries
ClaudeAgentSDK.query(prompt: 'Hi', transport: my_transport) { |msg| }

Breaking: ClaudeAgentOptions#append_allowed_tools has been removed — it emitted a flag no CLI version accepts, so every use already failed at connect. Use allowed_tools instead.

What's changed

  • #38 Audit fixes batch 1+2: teardown/liveness, control protocol, observer/OTel, permission context — @ya-luotao
  • #39 Audit fixes batches 3-6: parity, sessions, MCP overhaul, medium bugs, final items — @ya-luotao

Full Changelog: v0.17.0...v0.18.0


gem install claude-agent-sdk -v 0.18.0