Skip to content

v0.36.0

Choose a tag to compare

@ya-luotao ya-luotao released this 23 Sep 12:10
· 360 commits to main since this release

The first step on the road to 1.0: one session_store: argument for every session function (the store-specific twins are deprecated), ClaudeAgentSDK.ask, String tool results, and the last audit follow-ups (#119–#121). Read before upgrading:

  • An exit, Interrupt or signal raised inside a hook, can_use_tool or SDK MCP handler now ends the process after the CLI gets its error response. Since 0.34.0 a tool handler's exit was turned into an isError result and the process kept running, which also swallowed a real Ctrl-C or SIGTERM in :inline mode.
  • The *_from_store / *_via_store session functions print a one-time deprecation warning; switch to list_sessions(session_store: store) etc. (table under Deprecated).
  • Local-disk session APIs raise ConfigDirError (not ArgumentError) on hosts without a home directory; a session with no prompt has first_prompt nil on the disk path too (was '').

Added

  • SDK MCP tool handlers may return a String. create_tool('greet', ...) { |args| "Hello, #{args[:name]}!" } now sends Claude a single text block, the same as returning { content: [{ type: 'text', text: "Hello, ..." }] }. Both dispatch paths (tools/call through the MCP server and the direct SdkMcpServer#call_tool) accept it. Hash returns behave exactly as before and remain the form for is_error, structured_content, images and several blocks; any other non-Hash return still produces the in-band "must return a hash with :content key" error. The create_tool YARD examples, README and docs/mcp-servers.md (new "Handler Return Values" section) lead with the String form.
  • ClaudeAgentSDK.ask(prompt, options: nil) — runs query to completion and returns the final ResultMessage, so ClaudeAgentSDK.ask("What is 2 + 2?").result is the answer text, with cost, usage and session_id on the same object. It is query underneath: same prompt types (String or Enumerable), same options: and transport:, same errors (a terminal error exit still raises ResultError; an is_error result that is not followed by an error exit is returned like any other). An optional block receives every message as it arrives, so callers can stream progress and still get the result. It consumes the whole stream and returns the last ResultMessage; if the stream ends without one it raises CLIConnectionError. The README Quick Start now leads with it.
  • Ruby-style Client method names next to the Python-parity ones: client.model = 'haiku' (set_model), client.permission_mode = 'plan' (set_permission_mode), client.context_usage (get_context_usage) and client.mcp_status (get_mcp_status). Each delegates to its parity counterpart, so both spellings send the same control request, raise the same CLIConnectionError before connect, and an override of the parity method covers both. The parity names stay, so code ported from the Python docs keeps working. (server_info / get_server_info already existed as a pair.) docs/client.md lists both spellings.
  • CLIInstaller.root — a configurable root for the vendored CLI. CLIInstaller.default_dir (and so install, install_pinned, installed_path without dir:, and the transport's discovery of the vendored binary) is vendor/claude under CLIInstaller.root when it is set, and under the working directory at call time when it is nil, the default. A process whose cwd is not the project root, such as a daemonized worker, can now find the vendored binary instead of falling through to PATH. The setter takes a String or Pathname, absolutizes it once, and rejects an empty path; nil restores the working-directory default. Unset, nothing changes, and an explicit dir: still wins.
  • The Railtie anchors CLI discovery to Rails.root: an initializer sets CLIInstaller.root ||= Rails.root before config/initializers run, so an app initializer can override it and a root set in config/application.rb is kept. The claude_agent_sdk:install_cli task, which does not boot the app, installs under an explicitly set CLIInstaller.root if there is one, and under Rails.root/vendor/claude as before otherwise. The generated initializer's commented cli_path: line no longer describes a cwd workaround. docs/cli-installer.md gains a "Where vendor/claude is" section.
    Groundwork for 1.0 (#126): the sessions API collapses to one function per operation, and the store-specific twins are deprecated. Nothing is removed; every existing call keeps working.
  • session_store: on every session function. list_sessions, get_session_info, get_session_messages, list_subagents, get_subagent_metadata, get_subagent_messages, rename_session, tag_session, delete_session and fork_session take an optional session_store:. Omitted or nil, they work on local disk exactly as before; given a store, they run the same code the *_from_store / *_via_store function did, with the same arguments. Two differences between the paths are documented in docs/sessions.md: directory: nil means every project on disk but the current working directory with a store (a store cannot enumerate projects), and include_worktrees: is disk-only: with session_store:, list_sessions accepts only the default true and raises ArgumentError for false or nil instead of silently ignoring the filter.
  • ClaudeAgentSDK::ConfigDirError (a ClaudeSDKError), raised by the local-disk session APIs when the Claude config directory cannot be located: CLAUDE_CONFIG_DIR is unset and there is no usable home directory for the default ~/.claude. Its message says to set CLAUDE_CONFIG_DIR (#120).
  • CI: a macOS leg (Ruby 3.4) for the main suite; simplecov coverage (COVERAGE=1 bundle exec rspec) on the Linux Ruby 3.4 leg, with line/branch totals in the job summary and the HTML report as an artifact; and a weekly real-CLI integration run (.github/workflows/integration.yml) against CLIInstaller::PINNED_CLI_VERSION, also triggered by PRs that touch the installer. Dependabot keeps the workflows' actions current.
  • CONTRIBUTING.md, SECURITY.md, and issue and pull request templates.

Changed

  • exit / Interrupt / signal exceptions from an SDK MCP tool handler terminate the process again, after the CLI gets its isError result. Since #77 (0.34.0) they were converted into an isError result and the process kept running. That also swallowed a real Ctrl-C or SIGTERM landing in an :inline handler. The isError text now names the exception class ("SystemExit: exit" rather than "exit"). SdkMcpServer#call_tool and #handle_message, called directly without a session, let these exceptions propagate instead of returning an isError result. (#119)
  • Root-module plumbing is tagged @api private and no longer appears in the generated YARD docs (.yardopts gains --hide-api private; --no-private alone never hid @api private objects): resolve_observers, extract_sdk_mcp_servers, convert_hooks_to_internal_format, configure_can_use_tool, extract_exclude_dynamic_sections, extract_system_prompt_snapshot, notify_observers, check_inline_isolation, extract_user_prompt_text, prompt_text_from_content, observing_prompt_stream, flexible_fetch, normalize_tool_result, and the tool-schema helpers deep_symbolize_keys, deep_normalize_schema, prebuilt_json_schema?, normalize_tool_schema, ruby_type_to_json_schema. They still work, but they are not part of the public API and move under ClaudeAgentSDK::Internal in 1.0. fold_session_summary stays public, since SessionStore adapters call it from #append. The existing @api private tags (FiberBoundary internals, CancellationSignal#cancel) are hidden too; #cancel's tag is moved to its own line, where YARD recognizes it.
  • examples/rails_actioncable_example.rb and examples/rails_background_job_example.rb use ClaudeAgentSDK::Client.open instead of hand-rolled Async { connect … ensure disconnect }.wait, matching docs/rails.md.
  • RuboCop targets Ruby 3.2, the gemspec floor (was 3.0). The resulting autocorrections (anonymous block forwarding, dropping require 'set') change no behavior.

Deprecated

  • The ten store-specific session functions, removed in 1.0. Each still returns exactly what it did before (a nil session_store: still fails rather than falling back to disk), and prints a one-time warning per method per process naming its replacement and the calling line, e.g. app/jobs/sync.rb:12: warning: ClaudeAgentSDK.list_sessions_from_store is deprecated and will be removed in 1.0; use ClaudeAgentSDK.list_sessions(session_store: store). The warning uses plain Kernel#warn, not category: :deprecated, because Ruby hides that category unless Warning[:deprecated] is enabled; -W0 / $VERBOSE = nil silences it.

    Deprecated Replacement
    list_sessions_from_store(session_store: s, ...) list_sessions(session_store: s, ...)
    get_session_info_from_store(session_store: s, ...) get_session_info(session_store: s, ...)
    get_session_messages_from_store(session_store: s, ...) get_session_messages(session_store: s, ...)
    list_subagents_from_store(session_store: s, ...) list_subagents(session_store: s, ...)
    get_subagent_metadata_from_store(session_store: s, ...) get_subagent_metadata(session_store: s, ...)
    get_subagent_messages_from_store(session_store: s, ...) get_subagent_messages(session_store: s, ...)
    rename_session_via_store(session_store: s, ...) rename_session(session_store: s, ...)
    tag_session_via_store(session_store: s, ...) tag_session(session_store: s, ...)
    delete_session_via_store(session_store: s, ...) delete_session(session_store: s, ...)
    fork_session_via_store(session_store: s, ...) fork_session(session_store: s, ...)

    All other arguments carry over unchanged. import_session_to_store is not affected.

Fixed

  • exit, Interrupt and signal exceptions raised while a hook, can_use_tool, or an SDK MCP resource reader / prompt generator runs no longer leave the CLI without an answer. The SDK now responds first, then re-raises. The CLI gets exactly the response an ordinary exception from that callback produces, naming the exception class ("SystemExit: exit", "Interrupt", "SignalException: SIGTERM"). For hooks and can_use_tool that is the error control response; for resources/read / prompts/get it is a JSON-RPC -32603 error. The transport flushes that response, and then the original exception propagates, so the process terminates as plain Ruby would: exit 3 exits with status 3, and Ctrl-C interrupts. Previously no response was written and the reactor stopped. In the default :thread scheduling, Ruby also re-raised the worker thread's SystemExit on the main thread, which answered with a misleading Cancelled. This covers every dispatch site: can_use_tool, hooks with and without a HookMatcher#timeout (both variants), resources, prompts and tool handlers. It also covers a real Ctrl-C / SIGTERM that MRI delivers to the main thread while an :inline callback runs there, and a :thread hook that calls exit after its timeout expired (only exit is carried out of such an abandoned worker: an Interrupt or signal exception it raises ends that thread alone, as in plain Ruby). Cancellation (Async::Stop, hook timeouts) still propagates unchanged. A callback_wrapper sees an internal StandardError carrier whose #cause is the original, so ensure-based wrappers still clean up; the original is re-raised even if the wrapper swallows the carrier. Observers and message blocks are unchanged. (#119)
  • Session APIs on hosts without a home directory (#120). With CLAUDE_CONFIG_DIR unset and HOME unset with no passwd entry (docker --user in a minimal image) or an empty/relative HOME:
    • the local-disk session APIs (list_sessions, get_session_*, list_subagents, rename_session / tag_session / delete_session / fork_session, import_session_to_store) raise ConfigDirError instead of a bare ArgumentError from ~ expansion;
    • a fresh session with a session_store no longer fails at connect. The transcript mirror cannot map the CLI's transcript files to store keys without a projects dir, so each unmappable batch is reported as a MirrorErrorMessage (with a nil key) and counted as dropped, while the session itself runs normally. SessionStores.projects_dir returns nil in this case instead of raising.
  • Store-backed resume seeds auth and settings from the home the CLI subprocess will use (#120). When options.env sets HOME, .credentials.json, settings.json / cowork_settings.json and .claude.json are now read from under that home, as CLAUDE_CONFIG_DIR already was, instead of the parent process's home. An empty or relative HOME there, or HOME => nil, counts as no home, so those files are skipped. The transcript mirror resolves the subprocess's default ~/.claude/projects the same way, so a HOME override no longer sends every mirror frame down the "not under projects dir" drop path.
  • Remaining disk/store session read inconsistencies (#121):
    • Store listings report SDKSessionInfo#last_modified as Integer epoch milliseconds, as documented, whatever shape the adapter's mtime has (ISO-8601 String, numeric String, Float, or now Time, e.g. an ActiveRecord updated_at). Previously only the ordering was coerced and the raw value leaked through. An unusable mtime (including non-finite numbers) reads as 0, the value it already sorted by.
    • continue_conversation with a session_store breaks equal-mtime ties by session_id, like the listings, instead of resuming whichever session the adapter listed first.
    • first_prompt is nil on the disk path too (it was '') when a session has no prompt, matching the store path and the Python SDK.
    • cwd: both paths take the first non-blank top-level cwd. The disk path took the first cwd even when empty (then fell back to the project path) and also matched cwd keys nested in tool inputs; the store fold now also skips whitespace-only values, so a sidecar no longer locks on one.
    • rename_session / tag_session / delete_session / fork_session and their _via_store counterparts validate session_id (and up_to_message_id) at the boundary like the readers: a non-String id raises ArgumentError ("Invalid session_id") instead of NoMethodError.
    • list_sessions deduplicates a session found in several project directories deterministically: newest last_modified, then the larger file, then the project directory that sorts first (the global scan now walks project directories in name order). Equal mtimes used to keep whichever copy the filesystem listed first.

Usage

result = ClaudeAgentSDK.ask('What is 2 + 2?')
puts result.result            # the answer text
puts result                   # [result: success, 1 turn, 2.1s, $0.0031]

greet = ClaudeAgentSDK.create_tool('greet', 'Greet a user', { name: :string }) { |args| "Hello, #{args[:name]}!" }

ClaudeAgentSDK.list_sessions(session_store: store)   # was list_sessions_from_store(session_store: store)

What's changed

  • #128 CI: macOS leg, coverage, scheduled real-CLI run; community files — @ya-luotao
  • #131 Tolerate HOME-less hosts and close residual disk/store session inconsistencies (#120, #121) — @ya-luotao
  • #129 Consolidate the sessions API behind session_store: and deprecate the store twins — @ya-luotao
  • #130 Additive DX: String tool results, ClaudeAgentSDK.ask, Ruby-style Client names, CLIInstaller.root — @ya-luotao
  • #127 Answer the CLI, then re-raise exit/Interrupt from user callbacks (#119) — @ya-luotao

Roadmap to 1.0: #126

Full Changelog: v0.35.0...v0.36.0


gem install claude-agent-sdk -v 0.36.0