claude-agent-sdk 1.3.0 → 1.4.1

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: 54ece5b925722a884cb054583cd1d54307736c8491fa9452dfb46bd3b522bd9c
4
- data.tar.gz: fc048c6247dc93e82be75ab1ce0a04bcf616409998c4c89ac8fb4defc1e83ae4
3
+ metadata.gz: 5207d9e2d85cd3a5b1c8a7f7267d74077dd8ed7c856361f7e8689401a5da0b4b
4
+ data.tar.gz: 644c33952692d891e3c46c8ad4d448e52ef06461b79bec66950b3fb24ab6d926
5
5
  SHA512:
6
- metadata.gz: 0bbd41108e645d575b0bc2603de574e4f727eb40a4cd9feabf111c11708196c0947ba7d492b870edfc7dbc7812e7166279aeecc3e6dc8bef7fa530baadfe694b
7
- data.tar.gz: 676ef6b0dd2f168f73fd820fd33528729b02d96656140a7b340ade9a7548495354341949a84ede20a737a3ea2ce62bc0fe11adbb26225af3911661b7a72afdf1
6
+ metadata.gz: 03ad7fab32ba205b5598c2237dd65a8a1b25786349da7f1c5815eb83ccc516206d89c1bbfaa9e7c2d0ccc2e5cf3256c8fda2cc92a7d3a48eb6a56c24d28678d8
7
+ data.tar.gz: 4b97b7f3cedb844e78a4d04c4d376f776f76c74e2822c6c6253a6aff8bececbfcce04a5c6a54b52d7348e5900987ea9f5179fe773aa763a06dc126a13b2942a6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.1] - 2026-10-25
11
+
12
+ ### Changed
13
+ - **`process_group: true`: a SIGKILL the SDK sends the CLI also goes to the CLI's process group.** A wedged CLI that the SDK had to kill could not stop its own children, so one still in its group that ignores stdin EOF (a stdio MCP server, for instance) kept running after the session; before `process_group` it would at least have got the terminal's Ctrl-C. The group SIGKILL goes out just before the SIGKILL of the CLI itself, whenever the SDK sends one (a normal teardown after its grace periods and SIGTERM, a cancelled teardown after its short grace, or a CLI that closed stdout and hung), and adds no delay. SIGTERM, including the one sent at interpreter exit, still goes to the CLI process only, so the CLI's own cleanup of its children still runs; a CLI that exits on SIGTERM or on its own gets no group signal, and processes that left its group are not reached. Only sessions with `process_group: true` on Unix are affected. This narrows what 1.4.0 documented ("the SDK still signals the CLI process, not its group"). See [`docs/options.md`](docs/options.md#process_group).
14
+
15
+ ## [1.4.0] - 2026-10-25
16
+
17
+ ### Added
18
+ - **`ClaudeAgentOptions#process_group`** starts the CLI process in a process group of its own (issue #175). In a terminal, Ctrl-C signals the whole foreground process group, so the CLI used to get the SIGINT along with your process and end the turn on its own before your code could decide what to do. With `process_group: true` the terminal's Ctrl-C no longer reaches the CLI, and your process owns it: call `Client#interrupt` or `disconnect` from ordinary code (not from inside the signal trap, where Ruby raises `ThreadError`). Off by default, so nothing changes unless you set it. Sends nothing to the CLI; uses `pgroup: true` on Unix and `new_pgroup: true` on Windows, where it is untested. Teardown is unchanged: the SDK still signals the CLI process, not its group. Ruby only: the Python SDK's equivalent (PR #617) is unmerged as of 2026-10-25. [`docs/options.md`](docs/options.md#process_group) covers the trap pattern, when the CLI stops, and the background-group caveat for tools that read the terminal.
19
+
20
+ ### Changed
21
+ - README: the Claude Code overview link (it led to a "Page not found" page) and the built-in tools link now point at `code.claude.com`. "Rails in a minute" is a section of its own after the Quick Start, and its job turns off auto-memory (`CLAUDE_CODE_DISABLE_AUTO_MEMORY`) as the Rails guide's does. The hooks example defines the two callbacks it passes, the custom-tool example runs a prompt, and the Examples table lists `subagent_status_example.rb`. What a session without credentials, or with a rejected key, returns is now described in a new "Authentication Failures" section of `docs/errors.md`.
22
+
10
23
  ## [1.3.0] - 2026-10-12
11
24
 
12
25
  ### Added
data/README.md CHANGED
@@ -8,12 +8,10 @@
8
8
  [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://rubydoc.info/gems/claude-agent-sdk)
9
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/LICENSE)
10
10
 
11
- A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-overview) agent runtime, built for running agents in production Ruby and Rails apps. It has the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs, plus what a Rails deploy needs around them: a generator and CLI-vendoring rake task, callbacks that are safe to touch ActiveRecord from, a pinned CLI binary, built-in OpenTelemetry tracing, and transcript mirroring to your own storage.
11
+ A Ruby SDK for the [Claude Code](https://code.claude.com/docs/en/overview) agent runtime, built for running agents in production Ruby and Rails apps. It has the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs, plus what a Rails deploy needs around them: a generator and CLI-vendoring rake task, callbacks that are safe to touch ActiveRecord from, a pinned CLI binary, built-in OpenTelemetry tracing, and transcript mirroring to your own storage.
12
12
 
13
13
  > **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/CHANGELOG.md) for the currently synced version.
14
14
 
15
- > **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/UPGRADING-1.0.md) has the checklist.
16
-
17
15
  ## Highlights
18
16
 
19
17
  - **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`).
@@ -36,6 +34,8 @@ gem 'claude-agent-sdk', '~> 1.0'
36
34
 
37
35
  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: 'rubycatco/claude-agent-sdk-ruby'`.
38
36
 
37
+ > **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/UPGRADING-1.0.md) has the checklist.
38
+
39
39
  **Prerequisites**
40
40
 
41
41
  - Ruby 3.2 or newer
@@ -57,31 +57,7 @@ The SDK holds no credentials of its own. The `claude` process it starts authenti
57
57
  - `CLAUDE_CODE_OAUTH_TOKEN`, a long-lived token for a Claude subscription (`claude setup-token` creates one), set the same way
58
58
  - a login the CLI has already stored for the user your process runs as (`claude auth login`)
59
59
 
60
- A machine with none of them, such as a fresh container or a CI runner, does not fail at startup. The first prompt comes back as an `AssistantMessage` whose `error` is `'authentication_failed'` (its text is "Not logged in · Please run /login", a command an SDK host cannot run), followed by a `ResultMessage` with `is_error` set. `query()` and `ask` then raise `ResultError` with `terminal_reason == 'api_error'`; a `Client` session stays open, so check the result's `is_error` there. A key or token the API rejects ends the same way, with `api_error_status` 401, but only after the CLI has retried: watch for `APIRetryMessage` (ten of them over about three minutes when tested) rather than waiting for the error. [docs/errors.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/errors.md) shows how to handle both.
61
-
62
- ### Rails in a minute
63
-
64
- ```bash
65
- bundle add claude-agent-sdk
66
- bin/rails generate claude_agent_sdk:install # config/initializers/claude_agent_sdk.rb + .gitignore entry
67
- bin/rails claude_agent_sdk:install_cli # the tested CLI into vendor/claude (also a Docker build step)
68
- ```
69
-
70
- ```ruby
71
- # app/jobs/summarize_ticket_job.rb
72
- class SummarizeTicketJob < ApplicationJob
73
- def perform(ticket)
74
- options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1)
75
- prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
76
-
77
- ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
78
- ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
79
- end
80
- end
81
- end
82
- ```
83
-
84
- The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers.
60
+ A machine with none of them, such as a fresh container or a CI runner, does not fail at startup: the first prompt fails instead, as a `ResultError` from `query()` and `ask` and as a `ResultMessage` with `is_error` set on a `Client`. [Authentication Failures](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/errors.md#authentication-failures) describes what arrives, for missing credentials and for a rejected key.
85
61
 
86
62
  ## Quick Start
87
63
 
@@ -176,6 +152,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
176
152
  mcp_servers: { tools: server },
177
153
  allowed_tools: ['mcp__tools__greet']
178
154
  )
155
+
156
+ puts ClaudeAgentSDK.ask('Greet Alice with the greet tool', options: options).result
179
157
  ```
180
158
 
181
159
  A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details.
@@ -185,14 +163,58 @@ A String return is sent to Claude as a single text block. Return a Hash instead
185
163
  Hooks run your Ruby code at any of the 33 lifecycle events (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, `PreCompact`, …) with typed inputs. Permission callbacks decide programmatically whether a tool call may proceed.
186
164
 
187
165
  ```ruby
166
+ # A PreToolUse hook: refuse any Bash command that contains `rm -rf`.
167
+ block_rm_rf = lambda do |input, _tool_use_id, _context|
168
+ next {} unless input.tool_input[:command].to_s.include?('rm -rf')
169
+
170
+ { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
171
+ permissionDecisionReason: 'rm -rf is not allowed' } }
172
+ end
173
+
174
+ # A permission callback: asked before a tool call that no rule already allows.
175
+ protect_env_files = lambda do |tool_name, input, _context|
176
+ if tool_name == 'Write' && input[:file_path].to_s.end_with?('.env')
177
+ ClaudeAgentSDK::PermissionResultDeny.new(message: '.env files are off limits')
178
+ else
179
+ ClaudeAgentSDK::PermissionResultAllow.new
180
+ end
181
+ end
182
+
188
183
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(
189
- hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [my_hook])] },
190
- can_use_tool: my_permission_callback
184
+ hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [block_rm_rf])] },
185
+ can_use_tool: protect_env_files
191
186
  )
192
187
  ```
193
188
 
194
189
  See [docs/hooks-and-permissions.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/hooks-and-permissions.md) for the full event list and worked examples.
195
190
 
191
+ ## Rails in a minute
192
+
193
+ ```bash
194
+ bundle add claude-agent-sdk
195
+ bin/rails generate claude_agent_sdk:install # config/initializers/claude_agent_sdk.rb + .gitignore entry
196
+ bin/rails claude_agent_sdk:install_cli # the tested CLI into vendor/claude (also a Docker build step)
197
+ ```
198
+
199
+ ```ruby
200
+ # app/jobs/summarize_ticket_job.rb
201
+ class SummarizeTicketJob < ApplicationJob
202
+ def perform(ticket)
203
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
204
+ tools: [], max_turns: 1, # text only, no built-in tools
205
+ env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # keeps one user's "remember…" out of the next user's session
206
+ )
207
+ prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}"
208
+
209
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
210
+ ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage)
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
216
+ The block runs on a plain thread, so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there (see [Request state does not follow into callbacks](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/rails.md#request-state-does-not-follow-into-callbacks)). [docs/rails.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/rails.md) continues with multi-turn sessions, ActionCable streaming, per-user isolation, and fiber workers.
217
+
196
218
  ## Documentation
197
219
 
198
220
  | Topic | Guide |
@@ -210,7 +232,7 @@ See [docs/hooks-and-permissions.md](https://github.com/rubycatco/claude-agent-sd
210
232
  | Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/types.md) |
211
233
  | Error handling, exception hierarchy, timeouts | [docs/errors.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/docs/errors.md) |
212
234
 
213
- API reference: [rubydoc.info/gems/claude-agent-sdk](https://rubydoc.info/gems/claude-agent-sdk). Available built-in tools: [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude).
235
+ API reference: [rubydoc.info/gems/claude-agent-sdk](https://rubydoc.info/gems/claude-agent-sdk). Available built-in tools: [Claude Code tools reference](https://code.claude.com/docs/en/tools-reference).
214
236
 
215
237
  ## Examples
216
238
 
@@ -219,7 +241,7 @@ Runnable scripts live in [`examples/`](https://github.com/rubycatco/claude-agent
219
241
  | Area | Examples |
220
242
  |------|----------|
221
243
  | Getting started | [quick_start](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/quick_start.rb) · [client](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/client_example.rb) · [streaming_input](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/streaming_input_example.rb) · [message_types](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/message_types_example.rb) · [error_handling](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/error_handling_example.rb) |
222
- | Sessions and output | [session_resumption](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb) · [structured_output](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb) · [extended_thinking](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/extended_thinking_example.rb) · [session_stores/](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md) |
244
+ | Sessions, subagents and output | [session_resumption](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb) · [subagent_status](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb) · [structured_output](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb) · [extended_thinking](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/extended_thinking_example.rb) · [session_stores/](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md) |
223
245
  | Tools and MCP | [mcp_calculator](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) · [mcp_resources_prompts](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) · [http_mcp_server](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/http_mcp_server_example.rb) |
224
246
  | Hooks and permissions | [hooks](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb) · [advanced_hooks](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb) · [lifecycle_hooks](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb) · [permission_callback](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb) |
225
247
  | Models and limits | [budget_control](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/budget_control_example.rb) · [fallback_model](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/fallback_model_example.rb) · [advisor](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/advisor_example.rb) · [bare_mode](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb) · [sandbox](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb) |
@@ -238,7 +260,7 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u
238
260
  | Hooks (all 33 events) | ✅ | 10 typed, the rest by name | ✅ |
239
261
  | Permission callbacks | ✅ | ✅ | ✅ |
240
262
  | Structured output | ✅ | ✅ | ✅ |
241
- | All 28 message types | ✅ | partial | ✅ |
263
+ | Typed classes for every message type | ✅ | partial | ✅ |
242
264
  | [Sandbox](https://github.com/anthropic-experimental/sandbox-runtime) settings | ✅ | partial | ✅ |
243
265
  | Bare mode (`--bare`) | ✅ | via `extra_args` | ✅ |
244
266
  | File checkpointing & rewind | ✅ | ✅ | ✅ |
@@ -249,7 +271,7 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u
249
271
  | Custom transport (pluggable I/O) | — | ✅ | ✅ |
250
272
  | Rails integration | — | — | ✅ |
251
273
 
252
- Types are plain Ruby classes with `attr_accessor` and keyword arguments, mirroring the field names of the TypeScript Zod schemas and Python dataclasses; there is no runtime type checking.
274
+ Types are plain Ruby classes with `attr_accessor` and keyword arguments, mirroring the field names of the TypeScript Zod schemas and Python dataclasses; there is no runtime type checking. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`.
253
275
 
254
276
  ## Claude Code plugin
255
277
 
@@ -272,7 +294,7 @@ RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite (
272
294
  BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs
273
295
  ```
274
296
 
275
- CI runs the suite and RuboCop on Ruby 3.2, 3.3, 3.4 and 4.0 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8, validates the RBS signatures and runs the suite under RBS runtime type checking. Weekly, and on PRs that touch the installer, the transport, `Query` or the message parser, it runs a keyless smoke test against the pinned CLI, and the integration suite as well when the repository has an API key. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`. See [CONTRIBUTING.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
297
+ CI runs the suite and RuboCop on Ruby 3.2 through 4.0, the Rails specs on Rails 7.1 and 8, and both RBS tasks. See [CONTRIBUTING.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and the CI matrix, and [spec/README.md](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout.
276
298
 
277
299
  ## Contributing
278
300
 
data/docs/client.md CHANGED
@@ -64,6 +64,8 @@ ClaudeAgentSDK::Client.open do |client|
64
64
  end
65
65
  ```
66
66
 
67
+ To handle Ctrl-C yourself in a terminal app (call `interrupt`, ask, or ignore it) rather than have it stop the CLI too, see [`process_group`](options.md#process_group).
68
+
67
69
  The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged:
68
70
 
69
71
  | Ruby style | Python-parity name |
data/docs/errors.md CHANGED
@@ -87,6 +87,32 @@ initial handshake that was still in flight when the CLI exited. Match on
87
87
  `Resume rejected by --resume-drops-turn:` in the message and treat it as
88
88
  deterministic: clear the fork target and resume plainly rather than retrying.
89
89
 
90
+ ## Authentication Failures
91
+
92
+ The SDK holds no credentials; the CLI authenticates itself (see the README's
93
+ [Authentication](../README.md#authentication)). Neither of the two ways that
94
+ can fail is reported when the session starts.
95
+
96
+ **No credentials at all** (a fresh container, a CI runner). The first prompt
97
+ comes back as an `AssistantMessage` whose `error` is `'authentication_failed'`
98
+ (its text is "Not logged in · Please run /login", a command an SDK host cannot
99
+ run), followed by a `ResultMessage` with `is_error` set. `query()` and `ask`
100
+ then raise `ResultError` with `terminal_reason == 'api_error'`. A `Client`
101
+ session stays open, so check the result's `is_error` there.
102
+
103
+ **A key or token the API rejects.** It ends the same way, with
104
+ `api_error_status` 401, but only after the CLI has retried: ten
105
+ `APIRetryMessage`s over about three minutes when tested. Watch for them rather
106
+ than waiting for the error:
107
+
108
+ ```ruby
109
+ ClaudeAgentSDK.query(prompt: "Hello") do |message|
110
+ next unless message.is_a?(ClaudeAgentSDK::APIRetryMessage)
111
+
112
+ warn "API retry #{message.attempt}/#{message.max_retries}: HTTP #{message.error_status} (#{message.error})"
113
+ end
114
+ ```
115
+
90
116
  ## Errors from Client Control Methods
91
117
 
92
118
  `Client#interrupt`, `#set_model`, `#set_permission_mode`, `#rewind_files`, `#reconnect_mcp_server`, `#toggle_mcp_server`, `#stop_task` and the other control methods (the Ruby-style `model=` and `permission_mode=` included) send a request to the CLI and wait for its answer. They can fail in three ways:
data/docs/options.md CHANGED
@@ -170,6 +170,7 @@ Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#cust
170
170
  | `cwd` | `String \| Pathname` | `nil` | The working directory of the CLI process (and its `PWD`) |
171
171
  | `env` | `Hash[String \| Symbol, String?]` | `{}` | Merged over the environment the CLI process inherits. A `nil` value unsets the variable |
172
172
  | `user` | `String \| Integer` | `nil` | The user the CLI process runs as (name or uid; Unix, and the SDK process needs the privilege to switch) |
173
+ | `process_group` | `bool` | `false`; explicit `nil` stays `nil` | Nothing (SDK only): the CLI process starts in a process group of its own, out of reach of the terminal's Ctrl-C. See [`process_group`](#process_group) |
173
174
  | `extra_args` | `Hash[String \| Symbol, untyped]` | `{}` | `--<flag> <value>` per pair, `--<flag>` for a `nil` value. See [`extra_args`](#extra_args) |
174
175
  | `stderr` | `_StderrCallback` | `nil` | Nothing (SDK only): called with each line the CLI writes to stderr |
175
176
  | `debug_stderr` | `_Puts \| String` | `nil` | Nothing (SDK only): an IO-like object (`puts`) or a file path that receives each CLI stderr line |
@@ -177,6 +178,59 @@ Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#cust
177
178
 
178
179
  These options describe the process the SDK starts itself. A [custom transport](client.md#custom-transport) starts the CLI its own way and decides what to do with them.
179
180
 
181
+ ### `process_group`
182
+
183
+ In a terminal, Ctrl-C sends SIGINT to every process of the foreground process group. The CLI is in your process's group, so it gets the signal at the same moment your process does and ends the turn on its own, before your code can decide what to do: interrupt and keep the partial reply, ask whether to stop, or ignore the keypress. With `process_group: true` the CLI process the SDK starts for the session begins in a process group of its own, and the terminal's SIGINT no longer reaches it, nor the processes it starts that stay in its group (Bash tool commands, stdio MCP servers). The short `claude -v` version check that runs before it stays in your group.
184
+
185
+ It applies to `query()` and `Client` alike, on the default transport. A [custom transport](client.md#custom-transport) starts the CLI its own way. A transport subclass whose `#build_command` wraps the CLI in another program (`docker exec`, `ssh`) puts that local program in a group of its own; the option does not set the process group of a CLI on the far side. The Python SDK has no equivalent; the TypeScript SDK leaves it to a custom `spawnClaudeCodeProcess`.
186
+
187
+ Your process now owns Ctrl-C. To stop a turn, call `Client#interrupt`, or `Client#disconnect` to end the session; a one-shot `query()` has no handle to interrupt. Neither may be called inside a signal trap: they take a mutex, and Ruby raises `ThreadError` (`can't be called from trap context`). Let the trap only record the signal, and make the call from ordinary code:
188
+
189
+ ```ruby
190
+ interrupts = Thread::Queue.new
191
+ previous = trap('INT') { interrupts << :int } # nothing else in here: no SDK calls
192
+ begin
193
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(process_group: true)
194
+ ClaudeAgentSDK::Client.open(options: options) do |client|
195
+ watcher = Thread.new do
196
+ client.interrupt if interrupts.pop # on this thread, outside the trap
197
+ end
198
+ client.query('Refactor the parser')
199
+ client.receive_response { |message| puts message }
200
+ ensure
201
+ interrupts << nil # ends the watcher; the queue stays open for the trap
202
+ watcher&.join
203
+ end
204
+ ensure
205
+ trap('INT', previous) # Ctrl-C behaves as before once the session is over
206
+ end
207
+ ```
208
+
209
+ A plain script that does not trap INT is no worse off: Ctrl-C still raises `Interrupt` in your process, and the SDK's teardown stops the CLI as it does on any other exception.
210
+
211
+ When the CLI process stops:
212
+
213
+ - On `Client#disconnect`, or at the end of a `Client.open` block or a `query()` call, the usual teardown runs: the SDK closes the CLI's stdin, gives it a grace period to finish, then sends SIGTERM and finally SIGKILL.
214
+ - When the interpreter exits normally without a disconnect, an `at_exit` handler sends SIGTERM to the CLI process only, not its group. Best effort: it does not wait for the turn, and sends no SIGKILL.
215
+ - When your process dies abruptly (`exit!`, SIGKILL), the SDK cleans up nothing. The CLI sees its stdin close, which normally lets it wind down; how soon is up to the CLI and its background work (see [`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`](#claude_code_print_bg_wait_ceiling_ms)).
216
+
217
+ SIGTERM still goes to the CLI process only, so the CLI can stop the processes it started in its own order (it does so for stdio MCP servers). Whenever the SDK sends the CLI SIGKILL, that SIGKILL also goes to the CLI's process group, so processes still in that group die with the CLI instead of outliving it: a CLI that has to be killed cannot stop them itself, and with the option on they no longer get the terminal's SIGINT either. The group SIGKILL follows the SDK's own SIGKILL decisions and adds no delay of its own. The SDK sends the CLI SIGKILL when it is still running:
218
+
219
+ - on a normal teardown (disconnect, the end of a `Client.open` block or a `query()` call): after stdin EOF, a grace period, SIGTERM and a second grace period;
220
+ - when that teardown is itself cancelled before it got there (`Async::Stop`, a task timeout): SIGTERM at once, then SIGKILL after a short grace, with no grace for stdin EOF first;
221
+ - when the CLI has closed its stdout but kept running: after a grace period of its own, SIGTERM and a second grace period.
222
+
223
+ What it does not do:
224
+
225
+ - When the CLI exits on its own, or on SIGTERM, before a SIGKILL is due, the SDK sends its group nothing. Its children are the CLI's to stop; one that ignores the CLI's request is not chased.
226
+ - A process that moved to another process group is not reached.
227
+ - The SDK does not wait for or reap the processes the group SIGKILL reaches: they are the CLI's children, not the SDK's.
228
+ - On Windows nothing changes: the SDK signals the CLI process only.
229
+
230
+ The CLI's group is a background group of the terminal. A process in it that reads from the controlling terminal (a tool command that prompts on the tty) is stopped with SIGTTIN, and so is the rest of the group, the CLI included when the tool shares its group. Whether writing to the terminal stops it too depends on the terminal's TOSTOP setting. The SDK's own traffic with the CLI goes through pipes and is unaffected.
231
+
232
+ On Windows the option starts the CLI with `new_pgroup: true` (`CREATE_NEW_PROCESS_GROUP`), which also turns Ctrl-C off for the CLI process. It has not been exercised on Windows.
233
+
180
234
  ### `extra_args`
181
235
 
182
236
  The way to pass a CLI flag the SDK has no option for. A key is the flag's name without the leading dashes; the value follows it, and `nil` means a flag without a value:
@@ -373,8 +373,24 @@ module ClaudeAgentSDK
373
373
  # On Windows spawn raises for :uid, wrapped below into
374
374
  # CLIConnectionError — fail-loud instead of the old silent ignore.
375
375
  opts = { chdir: @cwd&.to_s, uid: @options.user }.compact
376
+ # process_group: the terminal's Ctrl-C signals the whole foreground
377
+ # process group, so a CLI left in ours ends the turn on its own
378
+ # before the host can choose to interrupt, ask, or ignore it. Its own
379
+ # group keeps that decision with the host. SIGTERM still goes to the
380
+ # pid only; a SIGKILL also goes to the group (#kill_process_group).
381
+ # Windows has no :pgroup; :new_pgroup (CREATE_NEW_PROCESS_GROUP) is
382
+ # its equivalent, and Unix rejects it. Absent unless set, like :uid.
383
+ # The `-v` probe is short-lived and bounded, and stays in our group.
384
+ opts[Gem.win_platform? ? :new_pgroup : :pgroup] = true if @options.process_group?
376
385
 
377
386
  @stdin, @stdout, @stderr, @process = Open3.popen3(process_env, *cmd, opts)
387
+ # Recorded from the spawn options themselves, on every spawn: only a
388
+ # process this line started with pgroup: true leads a group of its
389
+ # own (pgid == its pid). Kept by identity, so a KILL site asks about
390
+ # the waiter it holds, never the options, which can change after
391
+ # spawn. Not cleared by #close: a read loop still waiting on the
392
+ # same waiter must get the same answer.
393
+ @group_leader = opts[:pgroup] ? @process : nil
378
394
  # The CLI emits UTF-8 regardless of the parent locale. popen3 pipes
379
395
  # default to Encoding.default_external (US-ASCII under LANG=C/LC_ALL=C
380
396
  # — minimal Docker images, systemd, CI), which makes String#strip on
@@ -630,7 +646,14 @@ module ClaudeAgentSDK
630
646
  rescue Timeout::Error
631
647
  # SIGTERM didn't work — force kill
632
648
  begin
633
- Process.kill('KILL', @process.pid)
649
+ kill_process_group(@process.pid) if group_leader?(@process)
650
+ begin
651
+ Process.kill('KILL', @process.pid)
652
+ rescue Errno::ESRCH
653
+ # Already reaped: the group KILL just above reaches the CLI
654
+ # too, and its waiter can finish before this signal. Still
655
+ # wait for that waiter below, as the other KILL sites do.
656
+ end
634
657
  @process.value
635
658
  rescue StandardError => e
636
659
  raise if e.is_a?(Async::TimeoutError)
@@ -661,9 +684,12 @@ module ClaudeAgentSDK
661
684
  # inside an ensure during fiber cancellation: synchronous SIGTERM now,
662
685
  # then a plain (non-reactor) thread escalates to SIGKILL after a grace
663
686
  # period. Open3's Process::Waiter thread keeps reaping, so no zombie is
664
- # left either way. The alive? guard also makes the delayed KILL
665
- # pid-reuse-safe: while the waiter thread reports alive (not yet reaped),
666
- # the pid cannot have been recycled.
687
+ # left either way. The alive? guard before the delayed KILL is best
688
+ # effort: it narrows the window in which the pid could have been reaped
689
+ # and recycled, but does not close it — the waiter reports alive for a
690
+ # moment after its OS wait has reaped the child, and the child can be
691
+ # reaped between the check and the signal. The group KILL shares the
692
+ # pid KILL's check and instant, and so its window.
667
693
  #
668
694
  # @api private
669
695
  def force_terminate_in_background(process, grace_seconds: 2)
@@ -675,6 +701,8 @@ module ClaudeAgentSDK
675
701
  end
676
702
 
677
703
  pid = process.pid
704
+ # Captured now, on the caller's thread, for the KILL thread below.
705
+ kill_group = group_leader?(process)
678
706
  begin
679
707
  Process.kill('TERM', pid)
680
708
  rescue Errno::ESRCH
@@ -686,7 +714,10 @@ module ClaudeAgentSDK
686
714
  Thread.new do
687
715
  unless process.join(grace_seconds)
688
716
  begin
689
- Process.kill('KILL', pid) if process.alive?
717
+ if process.alive?
718
+ kill_process_group(pid) if kill_group
719
+ Process.kill('KILL', pid)
720
+ end
690
721
  rescue Errno::ESRCH
691
722
  # Still wait for the waiter when exit raced the signal.
692
723
  end
@@ -731,6 +762,31 @@ module ClaudeAgentSDK
731
762
  false
732
763
  end
733
764
 
765
+ # Whether +process+ is the waiter #connect spawned with pgroup: true,
766
+ # i.e. the leader of a process group of its own, with the pid as pgid.
767
+ #
768
+ # @api private
769
+ def group_leader?(process)
770
+ !process.nil? && @group_leader.equal?(process)
771
+ end
772
+
773
+ # SIGKILL to the process group the CLI was spawned to lead, sent just
774
+ # before every SIGKILL of the CLI itself (process_group: true only), so
775
+ # its children still in that group die with it instead of outliving a
776
+ # CLI that never got to stop them. SIGTERM stays on the pid: the CLI
777
+ # stops its children itself on TERM, in its own order. Group first, so
778
+ # the still-running CLI keeps the group id in use at the signal. Best
779
+ # effort, and it proves nothing about the CLI: the group can be empty
780
+ # (ESRCH) or still hold other members after the CLI left it, so the pid
781
+ # KILL that follows, its waits and its error handling stay as they are.
782
+ #
783
+ # @api private
784
+ def kill_process_group(pgid)
785
+ Process.kill('KILL', -pgid)
786
+ rescue Errno::ESRCH, Errno::EPERM
787
+ nil
788
+ end
789
+
734
790
  def write(data)
735
791
  raise CLIConnectionError, 'Cannot write to terminated process' if @process && !@process.alive?
736
792
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
@@ -885,6 +941,7 @@ module ClaudeAgentSDK
885
941
  # double-wait). Both are non-fatal — the message loop just exits.
886
942
  # Snapshot: a concurrent close() nils @process mid-wait.
887
943
  process = @process
944
+ kill_group = group_leader?(process)
888
945
  returncode = nil
889
946
  termsig = nil
890
947
  forced_exit = false
@@ -901,6 +958,7 @@ module ClaudeAgentSDK
901
958
  begin
902
959
  Process.kill('TERM', process.pid)
903
960
  unless process_exited_within?(process, EOF_TERM_GRACE_SECONDS)
961
+ kill_process_group(process.pid) if kill_group
904
962
  Process.kill('KILL', process.pid)
905
963
  # Even KILL can't reap a child stuck in uninterruptible kernel
906
964
  # I/O; bound this last wait too rather than block on #value.
@@ -84,6 +84,7 @@ module ClaudeAgentSDK
84
84
  self.strict_mcp_config = false
85
85
  self.forward_subagent_text = false
86
86
  self.verbatim_prompts = false
87
+ self.process_group = false
87
88
 
88
89
  super(merge_with_defaults(attributes || {}))
89
90
 
@@ -247,6 +248,47 @@ module ClaudeAgentSDK
247
248
  @verbatim_prompts = coerce_boolean(value)
248
249
  end
249
250
 
251
+ # Start the CLI process in a process group of its own. Defaults to
252
+ # `false`.
253
+ #
254
+ # In a terminal, Ctrl-C sends SIGINT to the whole foreground process
255
+ # group, and the CLI is in your process's group unless this is set: it
256
+ # ends the turn on its own at the same moment your process sees the
257
+ # signal, before you can decide what to do about it. When true, the
258
+ # terminal's SIGINT no longer reaches the CLI (nor the processes it
259
+ # starts in its own group), and stopping a turn is up to you:
260
+ # {Client#interrupt} or {Client#disconnect}, called from ordinary code,
261
+ # never from inside a signal trap.
262
+ #
263
+ # Teardown still sends SIGTERM to the CLI process only, so the CLI can
264
+ # stop its own children in order. Whenever the SDK has to SIGKILL the
265
+ # CLI (it is still running after the teardown's graces and SIGTERM, or a
266
+ # cancelled teardown's short grace), that SIGKILL also goes to the CLI's
267
+ # process group, so processes still in it die with the CLI instead of
268
+ # outliving it. Unix only; see docs/options.md for the limits.
269
+ #
270
+ # Applies to the CLI process the default transport starts for the
271
+ # session (`pgroup: true` on Unix, `new_pgroup: true` on Windows), not to
272
+ # the short `claude -v` version probe. SDK only: nothing is sent to the
273
+ # CLI. Ruby only: the Python SDK has no equivalent, and the TypeScript
274
+ # SDK leaves it to a custom `spawnClaudeCodeProcess`.
275
+ #
276
+ # Assigning coerces to a Boolean; {#process_group?} is the predicate
277
+ # form.
278
+ #
279
+ # @return [Boolean]
280
+ attr_reader :process_group
281
+
282
+ # @return [Boolean] {#process_group}, as a strict Boolean.
283
+ def process_group?
284
+ !!process_group
285
+ end
286
+
287
+ # @see #process_group
288
+ def process_group=(value)
289
+ @process_group = coerce_boolean(value)
290
+ end
291
+
250
292
  # Request model-generated progress summaries for subagent (`local_agent`)
251
293
  # tasks. `true` *requests* generation: while the CLI has it enabled, a
252
294
  # subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '1.3.0'
4
+ VERSION = '1.4.1'
5
5
  end
@@ -57,6 +57,7 @@ module ClaudeAgentSDK
57
57
  ?can_use_tool: _CanUseTool?,
58
58
  ?hooks: Hash[String | Symbol, Array[HookMatcher]?]?,
59
59
  ?user: (String | Integer)?,
60
+ ?process_group: bool?,
60
61
  ?agents: Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]]?,
61
62
  ?setting_sources: Array[String]?,
62
63
  ?skills: (String | Array[String])?,
@@ -158,6 +159,14 @@ module ClaudeAgentSDK
158
159
  # User (name or uid) to run the CLI process as.
159
160
  attr_accessor user: (String | Integer)?
160
161
 
162
+ # Start the CLI process in a process group of its own, so the terminal's
163
+ # Ctrl-C does not reach it (false by default).
164
+ attr_reader process_group: bool?
165
+
166
+ def process_group=: (untyped value) -> bool?
167
+
168
+ def process_group?: () -> bool
169
+
161
170
  attr_accessor agents: Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]]?
162
171
 
163
172
  # Entries of SETTING_SOURCES.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-12 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async