claude-agent-sdk 0.19.0 → 0.20.0
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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +1 -1
- data/lib/claude-agent-sdk.rb +8 -0
- data/lib/claude_agent_sdk/command_builder.rb +19 -7
- data/lib/claude_agent_sdk/instrumentation.rb +6 -0
- data/lib/claude_agent_sdk/message_parser.rb +16 -5
- data/lib/claude_agent_sdk/query.rb +10 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +5 -0
- data/lib/claude_agent_sdk/session_mutations.rb +15 -5
- data/lib/claude_agent_sdk/session_resume.rb +18 -2
- data/lib/claude_agent_sdk/sessions.rb +47 -17
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +16 -3
- data/lib/claude_agent_sdk/types.rb +21 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +60 -29
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
|
|
4
|
+
data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
|
|
7
|
+
data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Behavioral fix batch (Batch B) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #42). Every fix aligns code with a documented contract or Python SDK behavior and changes behavior only for inputs that previously produced wrong results, hangs, or crashes. Minor (not patch) because two fixes raise where the SDK previously reported success: a signal-killed CLI now raises `ProcessError`, and assistant messages without `message.model` now raise `MessageParseError`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- A CLI process killed by a signal (OOM-kill SIGKILL, SIGSEGV, ...) now raises `ProcessError` ("Command terminated by signal N", `exit_code: -N` — Python returncode parity) instead of reporting a **truncated** response as clean end-of-stream success.
|
|
16
|
+
- A trailing title-clearing entry (`{"customTitle":""}`, or whitespace-only) no longer silently drops the whole session from `list_sessions`/`get_session_info` disk listings: blank values now fall through the summary/title fallback chains exactly like the SessionStore path and Python (`or` semantics).
|
|
17
|
+
- `continue_conversation: true` with a SessionStore adapter that reports mtimes as Strings (the natural SQL-timestamp-through-JSON shape) no longer silently resumes the **oldest** session: String mtimes (ISO-8601 or numeric) are coerced and ordered chronologically, and mixed Integer/String lists no longer raise a bare `ArgumentError`. The conformance suite already pins the epoch-ms Numeric contract.
|
|
18
|
+
- One malformed line in a transcript head (non-Hash entry, string `message`, non-string `text`) no longer drops the whole session from disk listings — the guards ported from Python skip just the bad line. An assistant line whose tool_use input embeds `"type":"user"` can no longer donate its text as the session's first prompt.
|
|
19
|
+
- A user message block that leaks `StopIteration` (e.g. `.next` on an exhausted Enumerator) no longer silently ends message reception mid-turn — previously the `ResultMessage` was dropped and `receive_response`/`query()` returned as if the turn had completed; the error now propagates.
|
|
20
|
+
- Top-level `query()` now validates the prompt like `Client#query` and fails fast at the call site: a bare Hash (which would stream `[key, value]` garbage to the CLI) and non-String/non-`#each` prompts (which hung forever) raise `ArgumentError`.
|
|
21
|
+
- One-shot `query()` now sends `exclude_dynamic_sections` from a preset system prompt in the initialize request (it was silently dropped; `Client` and Python both send it).
|
|
22
|
+
- `ClaudeAgentOptions#dup_with` now deep-duplicates nested containers: mutating a derived variant (`variant.allowed_tools << 'Bash'`) no longer bleeds into the base options and every sibling variant — including the security-relevant allow/deny lists. Leaf objects (callbacks, SDK MCP server instances, store adapters) keep identity, mirroring `Configuration#default_options`. Container deep-dup (both here and in `Configuration#default_options` merging) now also preserves Hash/Array subclasses — a Rails `HashWithIndifferentAccess` config no longer flattens into a plain Hash whose symbol lookups silently return nil.
|
|
23
|
+
- Sandbox gating in command building now matches Python's `sandbox is not None`: `sandbox: true` (the boolean toggle) no longer crashes with `NoMethodError: undefined method 'empty?' for true`, and an explicit `sandbox: false` / `{}` is forwarded to the CLI instead of silently dropped — so `sandbox: false` can actually override a sandbox enabled in the settings JSON.
|
|
24
|
+
- `output_format: { type: 'json_schema' }` with a nil/absent schema no longer emits `--json-schema null` (which the CLI rejects at spawn); the flag is skipped, matching Python's `schema is not None` guard.
|
|
25
|
+
- A non-Hash `message` field in a user/assistant CLI message now raises the documented `MessageParseError` instead of a raw `TypeError`.
|
|
26
|
+
- Assistant messages missing `message.model` now raise `MessageParseError` (Python parity) instead of silently constructing `AssistantMessage(model: nil)`.
|
|
27
|
+
|
|
28
|
+
## [0.19.1] - 2026-07-03
|
|
29
|
+
|
|
30
|
+
Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `lib/claude-agent-sdk.rb` require shim: `Bundler.require` now loads the SDK in default Rails/Bundler apps. Previously Bundler tried `claude-agent-sdk` and `claude/agent/sdk`, silently swallowed both LoadErrors, and left `ClaudeAgentSDK` undefined until a confusing `NameError` at first use.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- `require 'claude_agent_sdk/instrumentation'` now loads the SDK core, so the documented Rails observability initializer (and `OTelObserver`'s own `@example`) works as the only require.
|
|
37
|
+
- Typed `McpStdioServerConfig` / `McpSSEServerConfig` / `McpHttpServerConfig` / `McpSdkServerConfig` objects passed directly in `mcp_servers` (without `.to_h`) now serialize to their wire hash instead of a `#<...>` string the CLI cannot parse — and `McpSdkServerConfig` instances now actually register their in-process server, so its tools connect.
|
|
38
|
+
- `create_tool` with a Symbol name produced a tool that was advertised in `tools/list` but failed every invocation with 'Tool not found'; names are now coerced to String.
|
|
39
|
+
- One stdout line carrying invalid UTF-8 bytes no longer aborts the whole message stream (which dropped all buffered valid frames, including a trailing `result`); the bad line is scrubbed, matching the version-probe path.
|
|
40
|
+
- `fork_session` no longer hangs forever on a transcript with a `parentUuid` cycle among progress entries.
|
|
41
|
+
- `fork_session` / `delete_session` no longer stop at a 0-byte transcript stub: they fall through to the worktree project dir holding the real transcript, mirroring the read path. A session whose *only* copy is a 0-byte stub now raises `Errno::ENOENT`, consistent with the read paths that already hide it.
|
|
42
|
+
- Long-lived `Client`s using SDK MCP servers no longer leak one finished internal task per synchronously-handled control request (MCP metadata requests, unsupported subtypes).
|
|
43
|
+
- An observer implementing none of the observer interface — most commonly a Class passed instead of an instance (`observers: [OTelObserver]`) — is now warned about and skipped instead of producing silent zero instrumentation.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- The gem packages git-tracked files only (previously a working-tree glob, which shipped stray untracked files under `lib/`/`docs/` in locally built gems). `gem build` from a tree with tracked-but-deleted files now fails fast instead of silently packaging whatever was on disk.
|
|
47
|
+
|
|
10
48
|
## [0.19.0] - 2026-06-29
|
|
11
49
|
|
|
12
50
|
### Added
|
data/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
|
|
|
68
68
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
69
69
|
|
|
70
70
|
# Or use a stable version from RubyGems
|
|
71
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
71
|
+
gem 'claude-agent-sdk', '~> 0.20.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler autorequire shim. The gem is named `claude-agent-sdk`, so
|
|
4
|
+
# `Bundler.require` tries `require 'claude-agent-sdk'` and then
|
|
5
|
+
# `require 'claude/agent/sdk'` — and silently swallows both LoadErrors when
|
|
6
|
+
# neither file exists, leaving the SDK unloaded in default Rails/Bundler
|
|
7
|
+
# apps. This file makes the gem-named require resolve to the real entry point.
|
|
8
|
+
require_relative 'claude_agent_sdk'
|
|
@@ -156,8 +156,13 @@ module ClaudeAgentSDK
|
|
|
156
156
|
cmd.push("--resume-session-at", @options.resume_session_at.to_s)
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
+
# Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
|
|
160
|
+
# Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
|
|
161
|
+
# must reach the CLI so it can override a sandbox enabled in the
|
|
162
|
+
# settings JSON (`sandbox: true`, the boolean toggle, used to crash on
|
|
163
|
+
# true.empty?; false and {} were silently dropped).
|
|
159
164
|
def append_settings(cmd)
|
|
160
|
-
return unless @options.settings ||
|
|
165
|
+
return unless @options.settings || !@options.sandbox.nil?
|
|
161
166
|
|
|
162
167
|
settings_hash = {}
|
|
163
168
|
settings_is_path = false
|
|
@@ -167,11 +172,11 @@ module ClaudeAgentSDK
|
|
|
167
172
|
begin
|
|
168
173
|
settings_hash = JSON.parse(@options.settings)
|
|
169
174
|
rescue JSON::ParserError
|
|
170
|
-
if @options.sandbox
|
|
171
|
-
settings_hash = load_settings_file(@options.settings)
|
|
172
|
-
else
|
|
175
|
+
if @options.sandbox.nil?
|
|
173
176
|
settings_is_path = true
|
|
174
177
|
cmd.push("--settings", @options.settings)
|
|
178
|
+
else
|
|
179
|
+
settings_hash = load_settings_file(@options.settings)
|
|
175
180
|
end
|
|
176
181
|
end
|
|
177
182
|
elsif @options.settings.is_a?(Hash)
|
|
@@ -179,9 +184,8 @@ module ClaudeAgentSDK
|
|
|
179
184
|
end
|
|
180
185
|
end
|
|
181
186
|
|
|
182
|
-
if !settings_is_path &&
|
|
183
|
-
|
|
184
|
-
settings_hash[:sandbox] = sandbox_hash unless sandbox_hash.empty?
|
|
187
|
+
if !settings_is_path && !@options.sandbox.nil?
|
|
188
|
+
settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
|
|
185
189
|
end
|
|
186
190
|
|
|
187
191
|
cmd.push("--settings", JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
|
|
@@ -292,6 +296,11 @@ module ClaudeAgentSDK
|
|
|
292
296
|
else
|
|
293
297
|
@options.output_format
|
|
294
298
|
end
|
|
299
|
+
# A json_schema output_format with a nil/absent schema must skip the
|
|
300
|
+
# flag — `--json-schema null` is rejected by the CLI (Python guards
|
|
301
|
+
# `schema is not None`).
|
|
302
|
+
return if schema.nil?
|
|
303
|
+
|
|
295
304
|
schema_json = schema.is_a?(String) ? schema : JSON.generate(schema)
|
|
296
305
|
cmd.push("--json-schema", schema_json)
|
|
297
306
|
end
|
|
@@ -306,6 +315,9 @@ module ClaudeAgentSDK
|
|
|
306
315
|
if @options.mcp_servers.is_a?(Hash)
|
|
307
316
|
servers_for_cli = {}
|
|
308
317
|
@options.mcp_servers.each do |name, config|
|
|
318
|
+
# Typed Mcp*ServerConfig objects serialize via their wire hash —
|
|
319
|
+
# without this they'd JSON-stringify as "#<...>" via to_s.
|
|
320
|
+
config = config.to_h if config.is_a?(Type)
|
|
309
321
|
servers_for_cli[name] = if config.is_a?(Hash) && config[:type] == "sdk"
|
|
310
322
|
config.except(:instance)
|
|
311
323
|
else
|
|
@@ -3,4 +3,10 @@
|
|
|
3
3
|
# Instrumentation adapters for ClaudeAgentSDK.
|
|
4
4
|
# Each adapter lazy-requires its external gem, so loading this file has zero cost
|
|
5
5
|
# unless you instantiate a specific observer.
|
|
6
|
+
#
|
|
7
|
+
# This entry point is documented as a standalone require (docs/rails.md's
|
|
8
|
+
# initializer, OTelObserver's @example), so it must load the SDK core too —
|
|
9
|
+
# otherwise ClaudeAgentSDK exists (observer.rb reopens the module) but has
|
|
10
|
+
# no .configure/.query/ClaudeAgentOptions.
|
|
11
|
+
require_relative '../claude_agent_sdk'
|
|
6
12
|
require_relative 'instrumentation/otel'
|
|
@@ -46,6 +46,9 @@ module ClaudeAgentSDK
|
|
|
46
46
|
tool_use_result = data[:tool_use_result]
|
|
47
47
|
message_data = data[:message]
|
|
48
48
|
raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
|
|
49
|
+
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
50
|
+
# message_data[:content] instead of the documented MessageParseError.
|
|
51
|
+
raise MessageParseError.new("Invalid message field in user message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
|
|
49
52
|
|
|
50
53
|
content = message_data[:content]
|
|
51
54
|
raise MessageParseError.new("Missing content in user message", data: data) unless content
|
|
@@ -61,19 +64,27 @@ module ClaudeAgentSDK
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def self.parse_assistant_message(data)
|
|
64
|
-
|
|
67
|
+
message_data = data[:message]
|
|
68
|
+
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
69
|
+
# dig instead of the documented MessageParseError.
|
|
70
|
+
raise MessageParseError.new("Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
content = message_data[:content]
|
|
65
73
|
raise MessageParseError.new("Missing content in assistant message", data: data) unless content
|
|
66
74
|
raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
|
|
67
75
|
|
|
68
76
|
content_blocks = parse_content_blocks(content, data)
|
|
69
77
|
AssistantMessage.new(
|
|
70
78
|
content: content_blocks,
|
|
71
|
-
model
|
|
79
|
+
# model is required, like Python — fetch raises KeyError when absent,
|
|
80
|
+
# which parse's rescue wraps into MessageParseError, instead of
|
|
81
|
+
# silently constructing model: nil.
|
|
82
|
+
model: message_data.fetch(:model),
|
|
72
83
|
parent_tool_use_id: data[:parent_tool_use_id],
|
|
73
84
|
error: data[:error], # authentication_failed, billing_error, rate_limit, invalid_request, server_error, unknown
|
|
74
|
-
usage:
|
|
75
|
-
message_id:
|
|
76
|
-
stop_reason:
|
|
85
|
+
usage: message_data[:usage],
|
|
86
|
+
message_id: message_data[:id],
|
|
87
|
+
stop_reason: message_data[:stop_reason],
|
|
77
88
|
session_id: data[:session_id],
|
|
78
89
|
uuid: data[:uuid]
|
|
79
90
|
)
|
|
@@ -256,7 +256,11 @@ module ClaudeAgentSDK
|
|
|
256
256
|
@inflight_control_request_tasks.delete(request_id) if request_id
|
|
257
257
|
end
|
|
258
258
|
end
|
|
259
|
-
|
|
259
|
+
# A handler that never suspends (MCP metadata, unsupported-subtype
|
|
260
|
+
# error path) already ran to completion inside the async{} above —
|
|
261
|
+
# its ensure-delete fired before this insert, so registering it here
|
|
262
|
+
# would leak a finished task in the map forever.
|
|
263
|
+
@inflight_control_request_tasks[request_id] = handler_task if request_id && !handler_task.finished?
|
|
260
264
|
when 'control_cancel_request'
|
|
261
265
|
request_id = message[:request_id] || message[:requestId]
|
|
262
266
|
task = request_id ? @inflight_control_request_tasks[request_id] : nil
|
|
@@ -1135,7 +1139,11 @@ module ClaudeAgentSDK
|
|
|
1135
1139
|
def receive_messages(&block)
|
|
1136
1140
|
return enum_for(:receive_messages) unless block
|
|
1137
1141
|
|
|
1138
|
-
loop
|
|
1142
|
+
# NOT Kernel#loop: it rescues StopIteration, so a user block leaking one
|
|
1143
|
+
# (e.g. calling .next on an exhausted Enumerator) would silently end
|
|
1144
|
+
# reception — ResultMessage dropped, the query reported as complete, and
|
|
1145
|
+
# on_error never fired. `while` propagates it like any other error.
|
|
1146
|
+
while true # rubocop:disable Style/InfiniteLoop
|
|
1139
1147
|
message = @message_queue.dequeue
|
|
1140
1148
|
break if message[:type] == 'end'
|
|
1141
1149
|
raise message[:error] if message[:type] == 'error'
|
|
@@ -470,6 +470,11 @@ module ClaudeAgentSDK
|
|
|
470
470
|
resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars
|
|
471
471
|
end
|
|
472
472
|
|
|
473
|
+
# tools/call arrives from the CLI with the name as a JSON String; the mcp
|
|
474
|
+
# gem keys its lookup on the value stored here, so a Symbol name would be
|
|
475
|
+
# advertised in tools/list yet miss on every invocation ('Tool not found').
|
|
476
|
+
name = name.to_s if name.is_a?(Symbol)
|
|
477
|
+
|
|
473
478
|
SdkMcpTool.new(
|
|
474
479
|
name: name,
|
|
475
480
|
description: description,
|
|
@@ -280,11 +280,17 @@ module ClaudeAgentSDK
|
|
|
280
280
|
nil
|
|
281
281
|
end
|
|
282
282
|
|
|
283
|
+
# A candidate counts only when it exists AND is non-empty — a 0-byte stub
|
|
284
|
+
# in one project dir must not stop the search when the real transcript
|
|
285
|
+
# lives under another (worktree) project dir. Mirrors the read path
|
|
286
|
+
# (Sessions.stat_candidate) and the append path (try_append).
|
|
283
287
|
def try_project_dir(file_name, project_dir)
|
|
284
288
|
return nil unless project_dir
|
|
285
289
|
|
|
286
290
|
candidate = File.join(project_dir, file_name)
|
|
287
|
-
File.
|
|
291
|
+
File.size(candidate).positive? ? [candidate, project_dir] : nil
|
|
292
|
+
rescue SystemCallError
|
|
293
|
+
nil
|
|
288
294
|
end
|
|
289
295
|
|
|
290
296
|
def find_in_all_projects(file_name)
|
|
@@ -295,8 +301,8 @@ module ClaudeAgentSDK
|
|
|
295
301
|
pd = File.join(projects_dir, child)
|
|
296
302
|
next unless File.directory?(pd)
|
|
297
303
|
|
|
298
|
-
|
|
299
|
-
return
|
|
304
|
+
result = try_project_dir(file_name, pd)
|
|
305
|
+
return result if result
|
|
300
306
|
end
|
|
301
307
|
nil
|
|
302
308
|
end
|
|
@@ -523,9 +529,13 @@ module ClaudeAgentSDK
|
|
|
523
529
|
JSON.generate(forked)
|
|
524
530
|
end
|
|
525
531
|
|
|
526
|
-
# Walk up parentUuid chain skipping progress entries.
|
|
532
|
+
# Walk up parentUuid chain skipping progress entries. The visited set
|
|
533
|
+
# guards against parentUuid cycles among progress entries (corrupt
|
|
534
|
+
# transcripts) like every other chain walker — an unguarded walk spun
|
|
535
|
+
# forever and hung both fork paths.
|
|
527
536
|
def resolve_parent_uuid(parent_id, by_uuid, uuid_mapping)
|
|
528
|
-
|
|
537
|
+
visited = Set.new
|
|
538
|
+
while parent_id && visited.add?(parent_id)
|
|
529
539
|
parent = by_uuid[parent_id]
|
|
530
540
|
break unless parent
|
|
531
541
|
return uuid_mapping[parent_id] if parent['type'] != 'progress'
|
|
@@ -151,7 +151,7 @@ module ClaudeAgentSDK
|
|
|
151
151
|
end
|
|
152
152
|
return nil if sessions.nil? || sessions.empty?
|
|
153
153
|
|
|
154
|
-
sessions.sort_by { |s| -(s['mtime']
|
|
154
|
+
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
155
155
|
sid = cand['session_id']
|
|
156
156
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
157
157
|
|
|
@@ -166,6 +166,22 @@ module ClaudeAgentSDK
|
|
|
166
166
|
nil
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
+
# Adapters contractually report mtime as an epoch-ms Numeric (the
|
|
170
|
+
# conformance suite asserts it), but SQL timestamps naturally arrive as
|
|
171
|
+
# ISO-8601 Strings through JSON. Unary minus on a String is String#-@
|
|
172
|
+
# (frozen-string dedup), so String mtimes sorted lexicographically
|
|
173
|
+
# ASCENDING — --continue silently resumed the OLDEST session — and mixed
|
|
174
|
+
# Integer/String lists raised a bare ArgumentError. Coerce defensively:
|
|
175
|
+
# numeric strings and ISO-8601 both order correctly; anything else sorts
|
|
176
|
+
# last rather than crashing resume.
|
|
177
|
+
def sortable_mtime(value)
|
|
178
|
+
case value
|
|
179
|
+
when Numeric then value
|
|
180
|
+
when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
|
|
181
|
+
else 0
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
169
185
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
170
186
|
# re-raising failures/timeouts as RuntimeError with context. The thread hop
|
|
171
187
|
# (FiberBoundary with a timeout always hops) both keeps the async scheduler
|
|
@@ -436,7 +452,7 @@ module ClaudeAgentSDK
|
|
|
436
452
|
value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
|
|
437
453
|
end
|
|
438
454
|
|
|
439
|
-
private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
|
|
455
|
+
private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
|
|
440
456
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
441
457
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
442
458
|
:resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
|
|
@@ -236,6 +236,19 @@ module ClaudeAgentSDK
|
|
|
236
236
|
str
|
|
237
237
|
end
|
|
238
238
|
|
|
239
|
+
# Python's `x or None` for the summary/title fallback chains: Ruby's ||
|
|
240
|
+
# treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
|
|
241
|
+
# would win over a real first prompt and then fail the summary presence
|
|
242
|
+
# check — silently dropping the whole session from disk listings (the
|
|
243
|
+
# store path, SessionSummary.presence, already falls through correctly).
|
|
244
|
+
# Whitespace-only counts as blank because the final gate strips.
|
|
245
|
+
def presence(val)
|
|
246
|
+
return nil if val.nil?
|
|
247
|
+
return nil if val.is_a?(String) && val.strip.empty?
|
|
248
|
+
|
|
249
|
+
val
|
|
250
|
+
end
|
|
251
|
+
|
|
239
252
|
# Extract the first meaningful user prompt from the head of a JSONL file
|
|
240
253
|
def extract_first_prompt_from_head(head)
|
|
241
254
|
command_fallback = nil
|
|
@@ -247,16 +260,8 @@ module ClaudeAgentSDK
|
|
|
247
260
|
next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
|
|
248
261
|
|
|
249
262
|
entry = JSON.parse(line, symbolize_names: false)
|
|
250
|
-
|
|
251
|
-
next unless
|
|
252
|
-
|
|
253
|
-
texts = if content.is_a?(String)
|
|
254
|
-
[content]
|
|
255
|
-
elsif content.is_a?(Array)
|
|
256
|
-
content.filter_map { |block| block['text'] if block.is_a?(Hash) && block['type'] == 'text' }
|
|
257
|
-
else
|
|
258
|
-
next
|
|
259
|
-
end
|
|
263
|
+
texts = user_entry_texts(entry)
|
|
264
|
+
next unless texts
|
|
260
265
|
|
|
261
266
|
texts.each do |text|
|
|
262
267
|
text = text.gsub(/\n+/, ' ').strip
|
|
@@ -278,6 +283,29 @@ module ClaudeAgentSDK
|
|
|
278
283
|
command_fallback || ''
|
|
279
284
|
end
|
|
280
285
|
|
|
286
|
+
# Text blocks of a genuine user entry, or nil when the line should be
|
|
287
|
+
# skipped. Shape guards ported from Python: the byte pre-filter can match
|
|
288
|
+
# `"type":"user"` nested inside a tool_use input on an assistant line, so
|
|
289
|
+
# the parsed type is rechecked; and a malformed head line (non-Hash entry,
|
|
290
|
+
# string `message`, non-string `text`) must skip just that line rather
|
|
291
|
+
# than blow up into read_session_lite's blanket rescue and silently drop
|
|
292
|
+
# the whole session from disk listings.
|
|
293
|
+
def user_entry_texts(entry)
|
|
294
|
+
return nil unless entry.is_a?(Hash) && entry['type'] == 'user'
|
|
295
|
+
|
|
296
|
+
message = entry['message']
|
|
297
|
+
return nil unless message.is_a?(Hash)
|
|
298
|
+
|
|
299
|
+
content = message['content']
|
|
300
|
+
if content.is_a?(String)
|
|
301
|
+
[content]
|
|
302
|
+
elsif content.is_a?(Array)
|
|
303
|
+
content.filter_map do |block|
|
|
304
|
+
block['text'] if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
281
309
|
# Read a single session file with lite (head/tail) strategy
|
|
282
310
|
def read_session_lite(file_path, project_path)
|
|
283
311
|
stat = File.stat(file_path)
|
|
@@ -311,15 +339,17 @@ module ClaudeAgentSDK
|
|
|
311
339
|
def build_session_info(file_path, head, tail, stat, project_path)
|
|
312
340
|
# User-set title (customTitle) wins over AI-generated title (aiTitle).
|
|
313
341
|
# Head fallback covers short sessions where the title entry may not be in tail.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
extract_json_string_field(head, '
|
|
342
|
+
# Each candidate passes through presence so a blank value (e.g. a
|
|
343
|
+
# trailing title-clearing entry) falls through instead of short-circuiting.
|
|
344
|
+
custom_title = presence(extract_json_string_field(tail, 'customTitle', last: true)) ||
|
|
345
|
+
presence(extract_json_string_field(head, 'customTitle', last: true)) ||
|
|
346
|
+
presence(extract_json_string_field(tail, 'aiTitle', last: true)) ||
|
|
347
|
+
presence(extract_json_string_field(head, 'aiTitle', last: true))
|
|
318
348
|
first_prompt = extract_first_prompt_from_head(head)
|
|
319
349
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
320
350
|
summary = custom_title ||
|
|
321
|
-
extract_json_string_field(tail, 'lastPrompt', last: true) ||
|
|
322
|
-
extract_json_string_field(tail, 'summary', last: true) ||
|
|
351
|
+
presence(extract_json_string_field(tail, 'lastPrompt', last: true)) ||
|
|
352
|
+
presence(extract_json_string_field(tail, 'summary', last: true)) ||
|
|
323
353
|
first_prompt
|
|
324
354
|
return nil if summary.nil? || summary.strip.empty?
|
|
325
355
|
|
|
@@ -1235,7 +1265,7 @@ module ClaudeAgentSDK
|
|
|
1235
1265
|
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1236
1266
|
:collect_agent_files, :parse_jsonl_entries,
|
|
1237
1267
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1238
|
-
:filter_visible_messages, :read_head_tail, :build_session_info,
|
|
1268
|
+
:filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
|
|
1239
1269
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
1240
1270
|
:derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
|
|
1241
1271
|
:filter_transcript_entries, :entries_to_messages,
|
|
@@ -494,6 +494,13 @@ module ClaudeAgentSDK
|
|
|
494
494
|
# an oversized line was fully allocated BEFORE the 1MB cap could
|
|
495
495
|
# fire — unbounded memory on hostile/buggy stdout.
|
|
496
496
|
@stdout.each_line("\n", @max_buffer_size + 1) do |line|
|
|
497
|
+
# stdout is UTF-8-tagged but the CLI can emit invalid bytes (echoed
|
|
498
|
+
# binary/latin-1 output). strip/lstrip below raise on invalid
|
|
499
|
+
# encoding, which would abort the whole stream and drop buffered
|
|
500
|
+
# valid frames — scrub the one bad line instead (the version-probe
|
|
501
|
+
# path guards the same way).
|
|
502
|
+
line = line.scrub unless line.valid_encoding?
|
|
503
|
+
|
|
497
504
|
# Position-aware whitespace handling: a chunk of an over-limit line
|
|
498
505
|
# must keep its interior whitespace — a blanket per-chunk strip
|
|
499
506
|
# deleted spaces inside JSON strings straddling the chunk boundary
|
|
@@ -549,9 +556,14 @@ module ClaudeAgentSDK
|
|
|
549
556
|
# concurrently and reset it) or already waited on (Errno::ECHILD on
|
|
550
557
|
# double-wait). Both are non-fatal — the message loop just exits.
|
|
551
558
|
returncode = nil
|
|
559
|
+
termsig = nil
|
|
552
560
|
begin
|
|
553
561
|
status = @process&.value
|
|
562
|
+
# exitstatus is nil when the child died from a signal (OOM-kill
|
|
563
|
+
# SIGKILL, SIGSEGV, ...) — that end-of-stream is a TRUNCATED response,
|
|
564
|
+
# not a clean success. Python surfaces it as a negative returncode.
|
|
554
565
|
returncode = status&.exitstatus
|
|
566
|
+
termsig = status.termsig if status&.signaled?
|
|
555
567
|
rescue Errno::ECHILD
|
|
556
568
|
# Process was already reaped (e.g., by close()); no exit status to surface.
|
|
557
569
|
returncode = nil
|
|
@@ -564,7 +576,7 @@ module ClaudeAgentSDK
|
|
|
564
576
|
# #close still sees @process (left set here) for its termination logic.
|
|
565
577
|
self.class.deregister_active_process(@process)
|
|
566
578
|
|
|
567
|
-
if returncode && returncode != 0
|
|
579
|
+
if termsig || (returncode && returncode != 0)
|
|
568
580
|
# Wait briefly for stderr thread to finish draining
|
|
569
581
|
@stderr_task&.join(1)
|
|
570
582
|
|
|
@@ -572,8 +584,9 @@ module ClaudeAgentSDK
|
|
|
572
584
|
stderr_text = 'No stderr output captured' if stderr_text.empty?
|
|
573
585
|
|
|
574
586
|
@exit_error = ProcessError.new(
|
|
575
|
-
"Command failed with exit code #{returncode}",
|
|
576
|
-
exit_code
|
|
587
|
+
termsig ? "Command terminated by signal #{termsig}" : "Command failed with exit code #{returncode}",
|
|
588
|
+
# Negative-signal exit_code mirrors Python's subprocess returncode.
|
|
589
|
+
exit_code: termsig ? -termsig : returncode,
|
|
577
590
|
stderr: stderr_text
|
|
578
591
|
)
|
|
579
592
|
raise @exit_error
|
|
@@ -1586,6 +1586,15 @@ module ClaudeAgentSDK
|
|
|
1586
1586
|
|
|
1587
1587
|
def dup_with(**changes)
|
|
1588
1588
|
new_options = self.dup
|
|
1589
|
+
# A shallow #dup shares nested containers, so mutating a derived copy
|
|
1590
|
+
# (e.g. `variant.allowed_tools << 'Bash'`) would bleed into the base and
|
|
1591
|
+
# every sibling — including the security-relevant allow/deny lists.
|
|
1592
|
+
# Deep-dup Hash/Array containers only; non-container values (procs,
|
|
1593
|
+
# SDK MCP server instances, store adapters) must keep their identity.
|
|
1594
|
+
new_options.instance_variables.each do |ivar|
|
|
1595
|
+
value = new_options.instance_variable_get(ivar)
|
|
1596
|
+
new_options.instance_variable_set(ivar, deep_dup_containers(value)) if value.is_a?(Hash) || value.is_a?(Array)
|
|
1597
|
+
end
|
|
1589
1598
|
changes.each { |key, value| new_options[key] = value }
|
|
1590
1599
|
new_options
|
|
1591
1600
|
end
|
|
@@ -1687,10 +1696,20 @@ module ClaudeAgentSDK
|
|
|
1687
1696
|
|
|
1688
1697
|
# Recurse ONLY into Hash/Array; leaves keep object identity (observer
|
|
1689
1698
|
# factories, callbacks, SDK MCP server instances must not be duped).
|
|
1699
|
+
# Rebuild via dup.clear (never Hash#to_h / Array#map) to preserve
|
|
1700
|
+
# container SUBCLASSES: to_h flattens e.g. Rails'
|
|
1701
|
+
# HashWithIndifferentAccess into a plain Hash, silently breaking symbol
|
|
1702
|
+
# lookups on the copy (config[:type] == 'sdk' → nil).
|
|
1690
1703
|
def deep_dup_containers(value)
|
|
1691
1704
|
case value
|
|
1692
|
-
when Hash
|
|
1693
|
-
|
|
1705
|
+
when Hash
|
|
1706
|
+
copy = value.dup.clear
|
|
1707
|
+
value.each { |k, v| copy[k] = deep_dup_containers(v) }
|
|
1708
|
+
copy
|
|
1709
|
+
when Array
|
|
1710
|
+
copy = value.dup.clear
|
|
1711
|
+
value.each { |v| copy << deep_dup_containers(v) }
|
|
1712
|
+
copy
|
|
1694
1713
|
else value
|
|
1695
1714
|
end
|
|
1696
1715
|
end
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -23,13 +23,60 @@ require 'securerandom'
|
|
|
23
23
|
|
|
24
24
|
# Claude Agent SDK for Ruby
|
|
25
25
|
module ClaudeAgentSDK
|
|
26
|
+
# The duck-typed observer surface probed by resolve_observers — implementing
|
|
27
|
+
# any one of these counts as an observer (see Observer's no-op defaults).
|
|
28
|
+
OBSERVER_INTERFACE = %i[on_user_prompt on_message on_error on_close].freeze
|
|
29
|
+
|
|
26
30
|
# Resolve observers array: callables (Proc/lambda) are invoked to produce
|
|
27
31
|
# a fresh instance per query/session (thread-safe); plain objects are used as-is.
|
|
28
32
|
# Array() guards against nil (e.g., when observers: nil is passed explicitly).
|
|
33
|
+
# Anything implementing none of the observer methods is warned about and
|
|
34
|
+
# skipped — most commonly a Class passed instead of an instance, which
|
|
35
|
+
# previously produced silent zero instrumentation (every notify raised
|
|
36
|
+
# NoMethodError, swallowed by notify_observers' error containment).
|
|
29
37
|
def self.resolve_observers(observers)
|
|
30
|
-
Array(observers).
|
|
31
|
-
obs.respond_to?(:call) ? obs.call : obs
|
|
38
|
+
Array(observers).filter_map do |obs|
|
|
39
|
+
resolved = obs.respond_to?(:call) ? obs.call : obs
|
|
40
|
+
if OBSERVER_INTERFACE.none? { |m| resolved.respond_to?(m) }
|
|
41
|
+
label = resolved.is_a?(Module) ? resolved : resolved.class
|
|
42
|
+
hint = resolved.is_a?(Module) ? " — pass an instance (#{resolved}.new) or a factory lambda" : ''
|
|
43
|
+
warn "ClaudeAgentSDK: ignoring observer #{label}: it implements none of #{OBSERVER_INTERFACE.join('/')}#{hint}"
|
|
44
|
+
next nil
|
|
45
|
+
end
|
|
46
|
+
resolved
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Internal: pull live SDK MCP server instances out of an mcp_servers Hash.
|
|
51
|
+
# Accepts both raw Hash configs and typed Mcp*ServerConfig objects — a
|
|
52
|
+
# McpSdkServerConfig passed without .to_h previously failed the Hash-only
|
|
53
|
+
# guard, so its in-process server was silently never registered.
|
|
54
|
+
def self.extract_sdk_mcp_servers(mcp_servers)
|
|
55
|
+
return {} unless mcp_servers.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
servers = {}
|
|
58
|
+
mcp_servers.each do |name, config|
|
|
59
|
+
config = config.to_h if config.is_a?(Type)
|
|
60
|
+
servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
|
|
32
61
|
end
|
|
62
|
+
servers
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Internal: pull exclude_dynamic_sections out of a preset system prompt for
|
|
66
|
+
# the initialize request (older CLIs ignore unknown initialize fields).
|
|
67
|
+
# Shared by Client#connect and the one-shot query() path.
|
|
68
|
+
def self.extract_exclude_dynamic_sections(system_prompt)
|
|
69
|
+
if system_prompt.is_a?(SystemPromptPreset)
|
|
70
|
+
eds = system_prompt.exclude_dynamic_sections
|
|
71
|
+
return eds if [true, false].include?(eds)
|
|
72
|
+
elsif system_prompt.is_a?(Hash)
|
|
73
|
+
type = system_prompt[:type] || system_prompt['type']
|
|
74
|
+
if type == 'preset'
|
|
75
|
+
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
|
|
76
|
+
return eds if [true, false].include?(eds)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
33
80
|
end
|
|
34
81
|
|
|
35
82
|
# Safely call a method on each observer, suppressing any errors.
|
|
@@ -341,6 +388,13 @@ module ClaudeAgentSDK
|
|
|
341
388
|
# puts message
|
|
342
389
|
# end
|
|
343
390
|
def self.query(prompt:, options: nil, transport: nil, &block)
|
|
391
|
+
# Validate BEFORE the block-less enum_for return so a bad prompt fails at
|
|
392
|
+
# the call site, not on first iteration. Mirrors Client#query: a bare Hash
|
|
393
|
+
# responds to #each and would stream [key, value] pairs' to_s garbage to
|
|
394
|
+
# the CLI; nil/Integer would hang forever waiting for input.
|
|
395
|
+
raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
|
|
396
|
+
raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})" unless prompt.is_a?(String) || prompt.respond_to?(:each)
|
|
397
|
+
|
|
344
398
|
return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
|
|
345
399
|
|
|
346
400
|
options ||= ClaudeAgentOptions.new
|
|
@@ -391,12 +445,7 @@ module ClaudeAgentSDK
|
|
|
391
445
|
transport.connect
|
|
392
446
|
|
|
393
447
|
# Extract SDK MCP servers
|
|
394
|
-
sdk_mcp_servers =
|
|
395
|
-
if configured_options.mcp_servers.is_a?(Hash)
|
|
396
|
-
configured_options.mcp_servers.each do |name, config|
|
|
397
|
-
sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
|
|
398
|
-
end
|
|
399
|
-
end
|
|
448
|
+
sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
|
|
400
449
|
|
|
401
450
|
hooks = nil
|
|
402
451
|
if configured_options.hooks
|
|
@@ -426,6 +475,7 @@ module ClaudeAgentSDK
|
|
|
426
475
|
hooks: hooks,
|
|
427
476
|
agents: configured_options.agents,
|
|
428
477
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
478
|
+
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
429
479
|
skills: configured_options.skills
|
|
430
480
|
)
|
|
431
481
|
|
|
@@ -903,19 +953,14 @@ module ClaudeAgentSDK
|
|
|
903
953
|
@transport.connect
|
|
904
954
|
|
|
905
955
|
# Extract SDK MCP servers
|
|
906
|
-
sdk_mcp_servers =
|
|
907
|
-
if configured_options.mcp_servers.is_a?(Hash)
|
|
908
|
-
configured_options.mcp_servers.each do |name, config|
|
|
909
|
-
sdk_mcp_servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
|
|
910
|
-
end
|
|
911
|
-
end
|
|
956
|
+
sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
|
|
912
957
|
|
|
913
958
|
# Convert hooks to internal format
|
|
914
959
|
hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
|
|
915
960
|
|
|
916
961
|
# Extract exclude_dynamic_sections from preset system prompt for the
|
|
917
962
|
# initialize request (older CLIs ignore unknown initialize fields)
|
|
918
|
-
exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
963
|
+
exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
919
964
|
|
|
920
965
|
# Create Query handler
|
|
921
966
|
@query_handler = Query.new(
|
|
@@ -1038,20 +1083,6 @@ module ClaudeAgentSDK
|
|
|
1038
1083
|
internal_hooks
|
|
1039
1084
|
end
|
|
1040
1085
|
|
|
1041
|
-
def extract_exclude_dynamic_sections(system_prompt)
|
|
1042
|
-
if system_prompt.is_a?(SystemPromptPreset)
|
|
1043
|
-
eds = system_prompt.exclude_dynamic_sections
|
|
1044
|
-
return eds if [true, false].include?(eds)
|
|
1045
|
-
elsif system_prompt.is_a?(Hash)
|
|
1046
|
-
type = system_prompt[:type] || system_prompt['type']
|
|
1047
|
-
if type == 'preset'
|
|
1048
|
-
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
|
|
1049
|
-
return eds if [true, false].include?(eds)
|
|
1050
|
-
end
|
|
1051
|
-
end
|
|
1052
|
-
nil
|
|
1053
|
-
end
|
|
1054
|
-
|
|
1055
1086
|
def writeln(string)
|
|
1056
1087
|
write string.end_with?("\n") ? string : "#{string}\n"
|
|
1057
1088
|
end
|
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: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Community Contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -119,6 +119,7 @@ files:
|
|
|
119
119
|
- docs/rails.md
|
|
120
120
|
- docs/sessions.md
|
|
121
121
|
- docs/types.md
|
|
122
|
+
- lib/claude-agent-sdk.rb
|
|
122
123
|
- lib/claude_agent_sdk.rb
|
|
123
124
|
- lib/claude_agent_sdk/command_builder.rb
|
|
124
125
|
- lib/claude_agent_sdk/configuration.rb
|