claude-agent-sdk 0.19.1 → 0.21.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 +31 -0
- data/README.md +1 -1
- data/docs/sessions.md +6 -0
- data/lib/claude_agent_sdk/command_builder.rb +24 -8
- data/lib/claude_agent_sdk/message_parser.rb +16 -5
- data/lib/claude_agent_sdk/query.rb +114 -5
- data/lib/claude_agent_sdk/session_resume.rb +35 -2
- data/lib/claude_agent_sdk/sessions.rb +110 -17
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +9 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +59 -23
- data/lib/claude_agent_sdk/types.rb +21 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +53 -34
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 408fe2ed3f2f1f00866e3cef0b1795e67dc50dafd3292fc016985f62c10f1b6e
|
|
4
|
+
data.tar.gz: 045b04fee0681072fe7e99c81ab7a99d51d2eec362baf282a45c8d825cc38135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91b04e46d137103527e3af91d089acc01be96b69a8b0e6902eecb603654567ee826c8284ac0f9b797f180af64220ecfe0d71c3ab6f5e755e03525d596fcff85e
|
|
7
|
+
data.tar.gz: fb7e3d0a535522e98ed3c7a0180f1ac35ca5057a29dfdb3429b07cd896375672c0c94e114d92809325680f599ca39d8dee2f7fc3a658a73fabd744cf4a987946
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.21.0] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
Design-decision fix batch (Batch C) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #43), plus three teardown-race hardenings from its adversarial review. Minor (not patch) because three fixes change observable behavior for previously-broken flows: a missing settings file with sandbox no longer raises, a raising initial prompt stream no longer notifies observers, and teardown can now preserve (instead of delete) the materialized resume dir.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `Query#close` (and therefore `Client#disconnect`) is now safe to call from any thread. Calling it from a FiberBoundary worker — e.g. a tool handler, hook, or permission callback ending the session — crashed with `NoMethodError` (nil `Fiber.scheduler`) and left the read/child tasks running, hanging the enclosing reactor. A transient reactor-side watcher now serves closes marshaled from foreign threads with identical semantics; once the reactor is gone, close falls back to a direct (fiber-dead-safe) teardown. `close` is also idempotent now — concurrent or repeated closes no longer re-run teardown against half-torn-down state.
|
|
16
|
+
- Eager-mode mirror failures during the last flush of a stream are reported before the `'end'` sentinel: the `MirrorErrorMessage` for an in-flight dropped batch could previously be enqueued after `'end'` and never delivered, violating the documented "failures surface as MirrorErrorMessage" guarantee.
|
|
17
|
+
- Disk session listings no longer report tool-argument text as the session summary/title: the head/tail byte-scans for `summary`/`customTitle`/`aiTitle`/`lastPrompt` now verify each match against the top level of its containing JSONL line (the same keys the SessionStore fold reads), so keys nested inside tool_use inputs (subagent/teammate tool arguments) are ignored. Lines truncated at the 64KB window edge keep the raw-scan value.
|
|
18
|
+
- Resume-from-store teardown no longer deletes the only copy of turns the mirror failed to persist: when the batcher dropped batches (tracked via `TranscriptMirrorBatcher#batches_dropped?` / `Query#mirror_batches_dropped?`, including drains cancelled mid-flight), the materialized temp `CLAUDE_CONFIG_DIR` is preserved — credential copies scrubbed, transcripts kept, warning with the path — instead of removed.
|
|
19
|
+
- A missing settings file with `sandbox` set now warns and continues with sandbox-only settings (Python parity) instead of raising `CLIConnectionError`.
|
|
20
|
+
- `Client#connect` no longer fires observer `on_error` for a raising initial Enumerator prompt: input-stream errors are swallowed-with-warn (documented `Observer#on_error` contract, `query()` and Python parity). Notifying them also marked still-live OTel traces as failed.
|
|
21
|
+
- Postgres reference adapter (`examples/session_stores/postgres_session_store.rb`): all DB round-trips now serialize through an internal mutex — mirror appends run on fresh worker threads and can overlap after a send-timeout abandon, which pg's single-connection thread rules forbid. The "a single PG::Connection suffices" concurrency guidance was wrong and has been rewritten.
|
|
22
|
+
|
|
23
|
+
## [0.20.0] - 2026-07-03
|
|
24
|
+
|
|
25
|
+
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`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- 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.
|
|
29
|
+
- 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).
|
|
30
|
+
- `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.
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
- 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`.
|
|
34
|
+
- 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).
|
|
35
|
+
- `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.
|
|
36
|
+
- 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.
|
|
37
|
+
- `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.
|
|
38
|
+
- A non-Hash `message` field in a user/assistant CLI message now raises the documented `MessageParseError` instead of a raw `TypeError`.
|
|
39
|
+
- Assistant messages missing `message.model` now raise `MessageParseError` (Python parity) instead of silently constructing `AssistantMessage(model: nil)`.
|
|
40
|
+
|
|
10
41
|
## [0.19.1] - 2026-07-03
|
|
11
42
|
|
|
12
43
|
Zero-risk fix batch from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #41).
|
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.21.0'
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
|
data/docs/sessions.md
CHANGED
|
@@ -152,6 +152,12 @@ during resume materialization, default `60_000`).
|
|
|
152
152
|
> `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
|
|
153
153
|
> programmatically via `ClaudeAgentOptions` are unaffected. This matches the
|
|
154
154
|
> Python and TypeScript SDKs.
|
|
155
|
+
>
|
|
156
|
+
> The temp dir is deleted at disconnect — **unless the mirror dropped batches**
|
|
157
|
+
> (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
|
|
158
|
+
> the store copy is then incomplete and the temp dir holds the only copy of the
|
|
159
|
+
> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
|
|
160
|
+
> and warns with the preserved path so you can import them into the store.
|
|
155
161
|
|
|
156
162
|
### Implementing an adapter
|
|
157
163
|
|
|
@@ -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
|
|
@@ -367,7 +376,14 @@ module ClaudeAgentSDK
|
|
|
367
376
|
end
|
|
368
377
|
|
|
369
378
|
def load_settings_file(path)
|
|
370
|
-
|
|
379
|
+
# Missing file: warn and continue with sandbox-only settings (Python
|
|
380
|
+
# parity: logger.warning("Settings file not found: ...") and an empty
|
|
381
|
+
# settings object). Raising here turned a misconfiguration the CLI
|
|
382
|
+
# tolerates into a hard connect failure.
|
|
383
|
+
unless File.file?(path)
|
|
384
|
+
warn "Claude SDK: Settings file not found: #{path}"
|
|
385
|
+
return {}
|
|
386
|
+
end
|
|
371
387
|
|
|
372
388
|
JSON.parse(File.read(path))
|
|
373
389
|
end
|
|
@@ -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
|
)
|
|
@@ -77,6 +77,16 @@ module ClaudeAgentSDK
|
|
|
77
77
|
@closed = false
|
|
78
78
|
@initialization_result = nil
|
|
79
79
|
@transcript_mirror_batcher = nil
|
|
80
|
+
|
|
81
|
+
# Cross-thread close marshaling (see #close). Thread::Queue is the one
|
|
82
|
+
# primitive that is both fiber-scheduler-aware on the reactor side and
|
|
83
|
+
# thread-safe on the caller side (push -> scheduler#unblock).
|
|
84
|
+
@close_requests = ::Thread::Queue.new
|
|
85
|
+
@close_watcher = nil
|
|
86
|
+
@owning_scheduler = nil
|
|
87
|
+
# First-caller-wins guard for close_now (see there).
|
|
88
|
+
@close_mutex = Mutex.new
|
|
89
|
+
@close_started = false
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
# Initialize control protocol if in streaming mode
|
|
@@ -178,7 +188,24 @@ module ClaudeAgentSDK
|
|
|
178
188
|
parent = Async::Task.current?
|
|
179
189
|
raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
|
|
180
190
|
|
|
191
|
+
@owning_scheduler = Fiber.scheduler
|
|
181
192
|
@task = parent.async { read_messages }
|
|
193
|
+
# Reactor-side agent for #close calls arriving from foreign threads
|
|
194
|
+
# (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
|
|
195
|
+
# the owning thread's Fiber.scheduler, so the off-thread caller hands the
|
|
196
|
+
# whole close over and waits. Transient: must never keep the reactor
|
|
197
|
+
# alive, and is stopped automatically when the parent task finishes.
|
|
198
|
+
# One-shot: after serving a close it is done; a reactor-side close wakes
|
|
199
|
+
# it via @close_requests.close (pop -> nil) so it exits without serving.
|
|
200
|
+
@close_watcher = parent.async(transient: true) do
|
|
201
|
+
if (reply = @close_requests.pop)
|
|
202
|
+
begin
|
|
203
|
+
close
|
|
204
|
+
ensure
|
|
205
|
+
reply << true
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
182
209
|
end
|
|
183
210
|
|
|
184
211
|
# Spawn a child task that is stopped by #close (mirrors the Python SDK's
|
|
@@ -206,6 +233,14 @@ module ClaudeAgentSDK
|
|
|
206
233
|
@transcript_mirror_batcher = batcher
|
|
207
234
|
end
|
|
208
235
|
|
|
236
|
+
# True when the mirror dropped at least one batch (store copy incomplete).
|
|
237
|
+
# Meaningful after #close, which runs the final flush. Consulted by the
|
|
238
|
+
# resume-from-store teardown to decide whether the materialized temp dir
|
|
239
|
+
# holds the only copy of some turns and must be preserved.
|
|
240
|
+
def mirror_batches_dropped?
|
|
241
|
+
!!@transcript_mirror_batcher&.batches_dropped?
|
|
242
|
+
end
|
|
243
|
+
|
|
209
244
|
# Synthesize a `mirror_error` system message and put it on the SDK message
|
|
210
245
|
# stream so consumers learn a mirror batch was dropped after exhausting
|
|
211
246
|
# retries. Non-blocking: the message queue is unbounded, so unlike the
|
|
@@ -1139,7 +1174,11 @@ module ClaudeAgentSDK
|
|
|
1139
1174
|
def receive_messages(&block)
|
|
1140
1175
|
return enum_for(:receive_messages) unless block
|
|
1141
1176
|
|
|
1142
|
-
loop
|
|
1177
|
+
# NOT Kernel#loop: it rescues StopIteration, so a user block leaking one
|
|
1178
|
+
# (e.g. calling .next on an exhausted Enumerator) would silently end
|
|
1179
|
+
# reception — ResultMessage dropped, the query reported as complete, and
|
|
1180
|
+
# on_error never fired. `while` propagates it like any other error.
|
|
1181
|
+
while true # rubocop:disable Style/InfiniteLoop
|
|
1143
1182
|
message = @message_queue.dequeue
|
|
1144
1183
|
break if message[:type] == 'end'
|
|
1145
1184
|
raise message[:error] if message[:type] == 'error'
|
|
@@ -1148,8 +1187,45 @@ module ClaudeAgentSDK
|
|
|
1148
1187
|
end
|
|
1149
1188
|
end
|
|
1150
1189
|
|
|
1151
|
-
# Close the query and transport
|
|
1190
|
+
# Close the query and transport.
|
|
1191
|
+
#
|
|
1192
|
+
# Callable from any thread. Async::Task#stop needs the reactor's
|
|
1193
|
+
# Fiber.scheduler (per-thread), which foreign callers — FiberBoundary
|
|
1194
|
+
# workers running a tool handler / hook that calls client.disconnect, or
|
|
1195
|
+
# plain user threads — don't have; stopping from one raised NoMethodError
|
|
1196
|
+
# and left the read/child tasks running. Such callers hand the close to
|
|
1197
|
+
# the reactor-side watcher (spawned in #start) and wait for it to finish,
|
|
1198
|
+
# so close semantics are identical regardless of the calling thread.
|
|
1152
1199
|
def close
|
|
1200
|
+
if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
|
|
1201
|
+
marshal_close_to_reactor
|
|
1202
|
+
else
|
|
1203
|
+
# Same scheduler (reactor-side caller, including the watcher itself),
|
|
1204
|
+
# or no live watcher: when the reactor is gone its task fibers are
|
|
1205
|
+
# dead, so stopping them no longer touches Fiber.scheduler.
|
|
1206
|
+
close_now
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
private
|
|
1211
|
+
|
|
1212
|
+
def close_now
|
|
1213
|
+
# First caller wins: a reactor-side close racing a watcher-served
|
|
1214
|
+
# foreign close (or a repeated disconnect) must not re-run teardown
|
|
1215
|
+
# against half-torn-down state — transport.close can suspend mid-reap,
|
|
1216
|
+
# and a second pass would race it. Later callers return immediately;
|
|
1217
|
+
# the SDK's outer teardown ensures (query() / Client#disconnect) close
|
|
1218
|
+
# the transport independently, so nothing is left dangling even if the
|
|
1219
|
+
# first pass failed partway.
|
|
1220
|
+
first_caller = @close_mutex.synchronize do
|
|
1221
|
+
if @close_started
|
|
1222
|
+
false
|
|
1223
|
+
else
|
|
1224
|
+
@close_started = true
|
|
1225
|
+
end
|
|
1226
|
+
end
|
|
1227
|
+
return unless first_caller
|
|
1228
|
+
|
|
1153
1229
|
@closed = true
|
|
1154
1230
|
# Wake pending control-request waiters (same shape as the read-loop
|
|
1155
1231
|
# rescue broadcast): close stops the read task with Async::Stop, which
|
|
@@ -1167,10 +1243,43 @@ module ClaudeAgentSDK
|
|
|
1167
1243
|
# Stop tracked child tasks (e.g. stream_input) before the read task and
|
|
1168
1244
|
# transport so a parked input stream can never keep the reactor alive
|
|
1169
1245
|
# (mirrors Python close() cancelling _child_tasks).
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1246
|
+
begin
|
|
1247
|
+
@child_tasks.each(&:stop)
|
|
1248
|
+
@child_tasks.clear
|
|
1249
|
+
@task&.stop
|
|
1250
|
+
rescue NoMethodError, FiberError => e
|
|
1251
|
+
# Schedulerless fallback close (the watcher died unserved) racing
|
|
1252
|
+
# reactor teardown: a task fiber can still be unwinding, and stopping
|
|
1253
|
+
# it needs the owning thread's Fiber.scheduler. The dying reactor
|
|
1254
|
+
# stops its own tasks; the transport close below (plain IO + kill)
|
|
1255
|
+
# still runs. On the reactor itself this is a real bug — re-raise.
|
|
1256
|
+
raise unless Fiber.scheduler.nil?
|
|
1257
|
+
|
|
1258
|
+
warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
|
|
1259
|
+
end
|
|
1173
1260
|
@transport.close
|
|
1261
|
+
# Release a still-parked close watcher: pop returns nil and it exits
|
|
1262
|
+
# without serving. Any foreign-thread close arriving after this point
|
|
1263
|
+
# falls back to a direct close (safe — the fibers are now dead).
|
|
1264
|
+
@close_requests.close
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Hand the close to the reactor and wait for completion. Polls watcher
|
|
1268
|
+
# liveness instead of waiting forever: if the reactor shuts down
|
|
1269
|
+
# concurrently (the transient watcher is stopped without serving the
|
|
1270
|
+
# request), no reply will ever arrive — fall back to a direct close,
|
|
1271
|
+
# which is safe once the reactor's fibers are dead.
|
|
1272
|
+
def marshal_close_to_reactor
|
|
1273
|
+
reply = ::Thread::Queue.new
|
|
1274
|
+
@close_requests << reply
|
|
1275
|
+
loop do
|
|
1276
|
+
return if reply.pop(timeout: 0.1)
|
|
1277
|
+
break unless @close_watcher&.alive?
|
|
1278
|
+
end
|
|
1279
|
+
close_now
|
|
1280
|
+
rescue ClosedQueueError
|
|
1281
|
+
# The push raced a reactor-side close_now that closed @close_requests.
|
|
1282
|
+
close_now
|
|
1174
1283
|
end
|
|
1175
1284
|
end
|
|
1176
1285
|
end
|
|
@@ -28,6 +28,23 @@ module ClaudeAgentSDK
|
|
|
28
28
|
def cleanup
|
|
29
29
|
SessionResume.rmtree_with_retry(@config_dir)
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
# Teardown when the transcript mirror dropped batches: the CLI's
|
|
33
|
+
# authoritative transcript lives in this temp dir, and the store copy is
|
|
34
|
+
# missing the dropped turns — deleting the dir would permanently lose
|
|
35
|
+
# them. Keep the transcripts (projects/), remove the redacted credential
|
|
36
|
+
# copies, and tell the user where the data is so they can import it into
|
|
37
|
+
# the store manually. Never raises.
|
|
38
|
+
def preserve_transcripts
|
|
39
|
+
['.credentials.json', '.claude.json'].each do |name|
|
|
40
|
+
FileUtils.rm_f(File.join(@config_dir, name))
|
|
41
|
+
end
|
|
42
|
+
warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
|
|
43
|
+
"Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
|
|
44
|
+
'deleting it — import it into your session store, then remove the directory.'
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}"
|
|
47
|
+
end
|
|
31
48
|
end
|
|
32
49
|
|
|
33
50
|
# Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
|
|
@@ -151,7 +168,7 @@ module ClaudeAgentSDK
|
|
|
151
168
|
end
|
|
152
169
|
return nil if sessions.nil? || sessions.empty?
|
|
153
170
|
|
|
154
|
-
sessions.sort_by { |s| -(s['mtime']
|
|
171
|
+
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
155
172
|
sid = cand['session_id']
|
|
156
173
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
157
174
|
|
|
@@ -166,6 +183,22 @@ module ClaudeAgentSDK
|
|
|
166
183
|
nil
|
|
167
184
|
end
|
|
168
185
|
|
|
186
|
+
# Adapters contractually report mtime as an epoch-ms Numeric (the
|
|
187
|
+
# conformance suite asserts it), but SQL timestamps naturally arrive as
|
|
188
|
+
# ISO-8601 Strings through JSON. Unary minus on a String is String#-@
|
|
189
|
+
# (frozen-string dedup), so String mtimes sorted lexicographically
|
|
190
|
+
# ASCENDING — --continue silently resumed the OLDEST session — and mixed
|
|
191
|
+
# Integer/String lists raised a bare ArgumentError. Coerce defensively:
|
|
192
|
+
# numeric strings and ISO-8601 both order correctly; anything else sorts
|
|
193
|
+
# last rather than crashing resume.
|
|
194
|
+
def sortable_mtime(value)
|
|
195
|
+
case value
|
|
196
|
+
when Numeric then value
|
|
197
|
+
when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
|
|
198
|
+
else 0
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
169
202
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
170
203
|
# re-raising failures/timeouts as RuntimeError with context. The thread hop
|
|
171
204
|
# (FiberBoundary with a timeout always hops) both keeps the async scheduler
|
|
@@ -436,7 +469,7 @@ module ClaudeAgentSDK
|
|
|
436
469
|
value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
|
|
437
470
|
end
|
|
438
471
|
|
|
439
|
-
private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
|
|
472
|
+
private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
|
|
440
473
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
441
474
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
442
475
|
:resolve_dir, :read_file_if_present, :chmod_owner_only, :copy_if_present, :env_value
|
|
@@ -213,6 +213,65 @@ module ClaudeAgentSDK
|
|
|
213
213
|
result
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
# Byte-scan for `"key":"` like extract_json_string_field, but verify each
|
|
217
|
+
# match by JSON-parsing its containing line and reading the key at the TOP
|
|
218
|
+
# LEVEL of the entry. The raw scan also matches keys nested inside
|
|
219
|
+
# tool_use inputs (real transcripts carry unescaped `"summary":"..."` in
|
|
220
|
+
# subagent/teammate tool arguments), which made the disk path report
|
|
221
|
+
# tool-argument text as the session summary/title while the store fold —
|
|
222
|
+
# which reads only top-level keys — disagreed. A line that doesn't parse
|
|
223
|
+
# (truncated at the head/tail window edge) keeps the raw-scan value: its
|
|
224
|
+
# top-level shape can't be checked, and dropping it would regress the
|
|
225
|
+
# common case of a true entry cut by the 64KB window.
|
|
226
|
+
def extract_top_level_string_field(text, key, last: false)
|
|
227
|
+
positions = field_match_positions(text, key)
|
|
228
|
+
positions.reverse! if last
|
|
229
|
+
parsed_lines = {}
|
|
230
|
+
positions.each do |idx, value_start|
|
|
231
|
+
line_start = (text.rindex("\n", idx) || -1) + 1
|
|
232
|
+
entry = parsed_lines.fetch(line_start) do
|
|
233
|
+
parsed_lines[line_start] = parse_containing_line(text, line_start, idx)
|
|
234
|
+
end
|
|
235
|
+
if entry
|
|
236
|
+
value = entry[key]
|
|
237
|
+
return value if value.is_a?(String)
|
|
238
|
+
|
|
239
|
+
next # parseable line without a top-level string value: nested/false match
|
|
240
|
+
end
|
|
241
|
+
value = extract_json_string_value(text, value_start)
|
|
242
|
+
return unescape_json_string(value) if value
|
|
243
|
+
end
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Match positions of both compact and spaced `"key":` patterns, sorted by
|
|
248
|
+
# position so first/last selection is by document order (the pattern-major
|
|
249
|
+
# order of extract_json_string_field is wrong when spacings mix).
|
|
250
|
+
def field_match_positions(text, key)
|
|
251
|
+
positions = []
|
|
252
|
+
["\"#{key}\":\"", "\"#{key}\": \""].each do |pattern|
|
|
253
|
+
pos = 0
|
|
254
|
+
while (idx = text.index(pattern, pos))
|
|
255
|
+
value_start = idx + pattern.length
|
|
256
|
+
positions << [idx, value_start]
|
|
257
|
+
pos = value_start
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
positions.sort_by!(&:first)
|
|
261
|
+
positions
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Parse the JSONL line containing byte offset +idx+. Returns the entry
|
|
265
|
+
# Hash, {} for parseable non-Hash lines (a match inside one is nested by
|
|
266
|
+
# definition), or nil when the line doesn't parse (window truncation).
|
|
267
|
+
def parse_containing_line(text, line_start, idx)
|
|
268
|
+
line_end = text.index("\n", idx) || text.length
|
|
269
|
+
entry = JSON.parse(text[line_start...line_end])
|
|
270
|
+
entry.is_a?(Hash) ? entry : {}
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
216
275
|
# Extract string value starting at pos (handles escapes)
|
|
217
276
|
def extract_json_string_value(text, start)
|
|
218
277
|
pos = start
|
|
@@ -236,6 +295,19 @@ module ClaudeAgentSDK
|
|
|
236
295
|
str
|
|
237
296
|
end
|
|
238
297
|
|
|
298
|
+
# Python's `x or None` for the summary/title fallback chains: Ruby's ||
|
|
299
|
+
# treats "" as truthy, so a CLI title-clearing entry ({"customTitle":""})
|
|
300
|
+
# would win over a real first prompt and then fail the summary presence
|
|
301
|
+
# check — silently dropping the whole session from disk listings (the
|
|
302
|
+
# store path, SessionSummary.presence, already falls through correctly).
|
|
303
|
+
# Whitespace-only counts as blank because the final gate strips.
|
|
304
|
+
def presence(val)
|
|
305
|
+
return nil if val.nil?
|
|
306
|
+
return nil if val.is_a?(String) && val.strip.empty?
|
|
307
|
+
|
|
308
|
+
val
|
|
309
|
+
end
|
|
310
|
+
|
|
239
311
|
# Extract the first meaningful user prompt from the head of a JSONL file
|
|
240
312
|
def extract_first_prompt_from_head(head)
|
|
241
313
|
command_fallback = nil
|
|
@@ -247,16 +319,8 @@ module ClaudeAgentSDK
|
|
|
247
319
|
next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
|
|
248
320
|
|
|
249
321
|
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
|
|
322
|
+
texts = user_entry_texts(entry)
|
|
323
|
+
next unless texts
|
|
260
324
|
|
|
261
325
|
texts.each do |text|
|
|
262
326
|
text = text.gsub(/\n+/, ' ').strip
|
|
@@ -278,6 +342,29 @@ module ClaudeAgentSDK
|
|
|
278
342
|
command_fallback || ''
|
|
279
343
|
end
|
|
280
344
|
|
|
345
|
+
# Text blocks of a genuine user entry, or nil when the line should be
|
|
346
|
+
# skipped. Shape guards ported from Python: the byte pre-filter can match
|
|
347
|
+
# `"type":"user"` nested inside a tool_use input on an assistant line, so
|
|
348
|
+
# the parsed type is rechecked; and a malformed head line (non-Hash entry,
|
|
349
|
+
# string `message`, non-string `text`) must skip just that line rather
|
|
350
|
+
# than blow up into read_session_lite's blanket rescue and silently drop
|
|
351
|
+
# the whole session from disk listings.
|
|
352
|
+
def user_entry_texts(entry)
|
|
353
|
+
return nil unless entry.is_a?(Hash) && entry['type'] == 'user'
|
|
354
|
+
|
|
355
|
+
message = entry['message']
|
|
356
|
+
return nil unless message.is_a?(Hash)
|
|
357
|
+
|
|
358
|
+
content = message['content']
|
|
359
|
+
if content.is_a?(String)
|
|
360
|
+
[content]
|
|
361
|
+
elsif content.is_a?(Array)
|
|
362
|
+
content.filter_map do |block|
|
|
363
|
+
block['text'] if block.is_a?(Hash) && block['type'] == 'text' && block['text'].is_a?(String)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
281
368
|
# Read a single session file with lite (head/tail) strategy
|
|
282
369
|
def read_session_lite(file_path, project_path)
|
|
283
370
|
stat = File.stat(file_path)
|
|
@@ -311,15 +398,21 @@ module ClaudeAgentSDK
|
|
|
311
398
|
def build_session_info(file_path, head, tail, stat, project_path)
|
|
312
399
|
# User-set title (customTitle) wins over AI-generated title (aiTitle).
|
|
313
400
|
# Head fallback covers short sessions where the title entry may not be in tail.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
401
|
+
# Each candidate passes through presence so a blank value (e.g. a
|
|
402
|
+
# trailing title-clearing entry) falls through instead of short-circuiting.
|
|
403
|
+
# Summary-chain fields use the top-level-verified scan: a raw byte scan
|
|
404
|
+
# also matches these keys nested inside tool_use inputs, reporting tool
|
|
405
|
+
# arguments as the session title/summary (and diverging from the store
|
|
406
|
+
# fold, which reads top-level keys only).
|
|
407
|
+
custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
|
|
408
|
+
presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
|
|
409
|
+
presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
|
|
410
|
+
presence(extract_top_level_string_field(head, 'aiTitle', last: true))
|
|
318
411
|
first_prompt = extract_first_prompt_from_head(head)
|
|
319
412
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
320
413
|
summary = custom_title ||
|
|
321
|
-
|
|
322
|
-
|
|
414
|
+
presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
|
|
415
|
+
presence(extract_top_level_string_field(tail, 'summary', last: true)) ||
|
|
323
416
|
first_prompt
|
|
324
417
|
return nil if summary.nil? || summary.strip.empty?
|
|
325
418
|
|
|
@@ -1235,7 +1328,7 @@ module ClaudeAgentSDK
|
|
|
1235
1328
|
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1236
1329
|
:collect_agent_files, :parse_jsonl_entries,
|
|
1237
1330
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1238
|
-
:filter_visible_messages, :read_head_tail, :build_session_info,
|
|
1331
|
+
:filter_visible_messages, :read_head_tail, :build_session_info, :presence, :user_entry_texts,
|
|
1239
1332
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
1240
1333
|
:derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
|
|
1241
1334
|
:filter_transcript_entries, :entries_to_messages,
|
|
@@ -556,9 +556,14 @@ module ClaudeAgentSDK
|
|
|
556
556
|
# concurrently and reset it) or already waited on (Errno::ECHILD on
|
|
557
557
|
# double-wait). Both are non-fatal — the message loop just exits.
|
|
558
558
|
returncode = nil
|
|
559
|
+
termsig = nil
|
|
559
560
|
begin
|
|
560
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.
|
|
561
565
|
returncode = status&.exitstatus
|
|
566
|
+
termsig = status.termsig if status&.signaled?
|
|
562
567
|
rescue Errno::ECHILD
|
|
563
568
|
# Process was already reaped (e.g., by close()); no exit status to surface.
|
|
564
569
|
returncode = nil
|
|
@@ -571,7 +576,7 @@ module ClaudeAgentSDK
|
|
|
571
576
|
# #close still sees @process (left set here) for its termination logic.
|
|
572
577
|
self.class.deregister_active_process(@process)
|
|
573
578
|
|
|
574
|
-
if returncode && returncode != 0
|
|
579
|
+
if termsig || (returncode && returncode != 0)
|
|
575
580
|
# Wait briefly for stderr thread to finish draining
|
|
576
581
|
@stderr_task&.join(1)
|
|
577
582
|
|
|
@@ -579,8 +584,9 @@ module ClaudeAgentSDK
|
|
|
579
584
|
stderr_text = 'No stderr output captured' if stderr_text.empty?
|
|
580
585
|
|
|
581
586
|
@exit_error = ProcessError.new(
|
|
582
|
-
"Command failed with exit code #{returncode}",
|
|
583
|
-
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,
|
|
584
590
|
stderr: stderr_text
|
|
585
591
|
)
|
|
586
592
|
raise @exit_error
|
|
@@ -55,12 +55,24 @@ module ClaudeAgentSDK
|
|
|
55
55
|
@pending = []
|
|
56
56
|
@pending_entries = 0
|
|
57
57
|
@pending_bytes = 0
|
|
58
|
+
# Batches that exhausted retries (or could not be keyed) and never
|
|
59
|
+
# reached the store. Written only under @lock; read cross-thread by
|
|
60
|
+
# #batches_dropped? (a plain Integer read is safe under the GVL).
|
|
61
|
+
@dropped_batches = 0
|
|
58
62
|
# Fiber-aware lock: the critical section blocks on SessionStore#append
|
|
59
63
|
# (a thread hop), so a Thread::Mutex would deadlock the reactor. The
|
|
60
64
|
# semaphore serializes drains so append ordering matches enqueue order.
|
|
61
65
|
@lock = Async::Semaphore.new(1)
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
# True when at least one batch of entries never reached the store — the
|
|
69
|
+
# mirror copy is incomplete. Consulted at teardown by the resume-from-store
|
|
70
|
+
# cleanup so the materialized temp dir (which then holds the only copy of
|
|
71
|
+
# the dropped turns) is preserved instead of deleted.
|
|
72
|
+
def batches_dropped?
|
|
73
|
+
@dropped_batches.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
# Buffer a frame; schedule an eager background flush if thresholds are
|
|
65
77
|
# exceeded. Synchronous and fire-and-forget.
|
|
66
78
|
#
|
|
@@ -112,27 +124,47 @@ module ClaudeAgentSDK
|
|
|
112
124
|
@pending_bytes = 0
|
|
113
125
|
|
|
114
126
|
errors = []
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
# Cancellation (Async::Stop — not a StandardError) delivered while
|
|
128
|
+
# waiting on the lock or inside the append's thread join loses the
|
|
129
|
+
# detached items without either rescue firing. Teardown would then read
|
|
130
|
+
# batches_dropped? as false and delete a materialized resume dir holding
|
|
131
|
+
# the only copy of these entries — count the batch as dropped unless the
|
|
132
|
+
# flush path ran to completion (do_flush counts its own failures).
|
|
133
|
+
accounted = items.empty?
|
|
134
|
+
begin
|
|
135
|
+
@lock.acquire do
|
|
136
|
+
# Emptiness is checked INSIDE the lock (matching the Python batcher):
|
|
137
|
+
# an empty #flush/#close still serializes behind any in-flight or
|
|
138
|
+
# queued drain, so they are true barriers — at result-yield and at
|
|
139
|
+
# teardown the store really is up to date, and Query#close can't stop
|
|
140
|
+
# the read task while a detached batch is still being appended.
|
|
141
|
+
next if items.empty?
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
do_flush(items, errors)
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
# do_flush already guards each append; this guards any remaining path
|
|
147
|
+
# so the "never raises" contract holds against future regressions.
|
|
148
|
+
@dropped_batches += 1
|
|
149
|
+
warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
accounted = true
|
|
152
|
+
|
|
153
|
+
# Report errors BEFORE releasing the lock: flush/close are barriers, so
|
|
154
|
+
# a caller observing flush completion must also observe the error
|
|
155
|
+
# report. Reporting after release let an in-flight eager drain enqueue
|
|
156
|
+
# its MirrorErrorMessage after the read loop's 'end' sentinel — past
|
|
157
|
+
# the point where consumers stop dequeuing, i.e. never delivered. The
|
|
158
|
+
# production on_error (Query#report_mirror_error) is a non-blocking
|
|
159
|
+
# queue push, so holding the lock across it costs nothing.
|
|
160
|
+
errors.each do |key, message|
|
|
161
|
+
@on_error.call(key, message)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
ensure
|
|
167
|
+
@dropped_batches += 1 unless accounted
|
|
136
168
|
end
|
|
137
169
|
end
|
|
138
170
|
|
|
@@ -149,6 +181,7 @@ module ClaudeAgentSDK
|
|
|
149
181
|
|
|
150
182
|
key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
|
|
151
183
|
if key.nil?
|
|
184
|
+
@dropped_batches += 1
|
|
152
185
|
warn "Claude SDK: [SessionStore] dropping mirror frame: filePath #{file_path} is not under " \
|
|
153
186
|
"#{@projects_dir} -- subprocess CLAUDE_CONFIG_DIR likely differs from parent (custom env / container?)"
|
|
154
187
|
next
|
|
@@ -180,8 +213,11 @@ module ClaudeAgentSDK
|
|
|
180
213
|
end
|
|
181
214
|
end
|
|
182
215
|
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
return if succeeded
|
|
217
|
+
|
|
218
|
+
@dropped_batches += 1
|
|
219
|
+
errors << [key, last_err.to_s]
|
|
220
|
+
warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}"
|
|
185
221
|
end
|
|
186
222
|
|
|
187
223
|
# Run SessionStore#append (user code) on a plain thread via FiberBoundary,
|
|
@@ -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
|
@@ -62,6 +62,23 @@ module ClaudeAgentSDK
|
|
|
62
62
|
servers
|
|
63
63
|
end
|
|
64
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
|
|
80
|
+
end
|
|
81
|
+
|
|
65
82
|
# Safely call a method on each observer, suppressing any errors.
|
|
66
83
|
# Each observer is invoked through FiberBoundary so that user code runs
|
|
67
84
|
# on a plain thread (no Fiber scheduler) even when called from inside
|
|
@@ -371,6 +388,13 @@ module ClaudeAgentSDK
|
|
|
371
388
|
# puts message
|
|
372
389
|
# end
|
|
373
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
|
+
|
|
374
398
|
return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
|
|
375
399
|
|
|
376
400
|
options ||= ClaudeAgentOptions.new
|
|
@@ -451,6 +475,7 @@ module ClaudeAgentSDK
|
|
|
451
475
|
hooks: hooks,
|
|
452
476
|
agents: configured_options.agents,
|
|
453
477
|
sdk_mcp_servers: sdk_mcp_servers,
|
|
478
|
+
exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
|
|
454
479
|
skills: configured_options.skills
|
|
455
480
|
)
|
|
456
481
|
|
|
@@ -530,8 +555,13 @@ module ClaudeAgentSDK
|
|
|
530
555
|
ensure
|
|
531
556
|
# Remove the materialized resume temp dir (which holds a redacted
|
|
532
557
|
# .credentials.json copy) AFTER the subprocess has exited, even when
|
|
533
|
-
# close itself raises
|
|
534
|
-
|
|
558
|
+
# close itself raises — unless the mirror dropped batches: the store
|
|
559
|
+
# copy is then incomplete and the temp dir holds the only copy of
|
|
560
|
+
# the dropped turns, so it is preserved (scrubbed of credentials)
|
|
561
|
+
# with a warning instead of deleted.
|
|
562
|
+
if materialized
|
|
563
|
+
query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
|
|
564
|
+
end
|
|
535
565
|
end
|
|
536
566
|
end
|
|
537
567
|
end.wait
|
|
@@ -884,6 +914,10 @@ module ClaudeAgentSDK
|
|
|
884
914
|
# state, and removes the materialized temp dir (which holds a redacted
|
|
885
915
|
# .credentials.json copy) — so disconnect can never leave the client
|
|
886
916
|
# half-open or leak the temp dir. The original error still propagates.
|
|
917
|
+
# Keep a handle on the query handler past the nil-out below: whether the
|
|
918
|
+
# mirror dropped batches is only final AFTER #close ran its last flush,
|
|
919
|
+
# and the materialized-dir decision at the bottom needs to ask it.
|
|
920
|
+
query_handler = @query_handler
|
|
887
921
|
begin
|
|
888
922
|
@query_handler&.close
|
|
889
923
|
ensure
|
|
@@ -893,9 +927,17 @@ module ClaudeAgentSDK
|
|
|
893
927
|
ensure
|
|
894
928
|
@transport = nil
|
|
895
929
|
@connected = false
|
|
896
|
-
# Remove the materialized resume temp dir AFTER the subprocess
|
|
930
|
+
# Remove the materialized resume temp dir AFTER the subprocess
|
|
931
|
+
# exited — unless the mirror dropped batches: the store copy is then
|
|
932
|
+
# incomplete and the temp dir holds the only copy of the dropped
|
|
933
|
+
# turns, so it is preserved (scrubbed of credentials) with a warning
|
|
934
|
+
# instead of deleted.
|
|
897
935
|
if @materialized
|
|
898
|
-
|
|
936
|
+
if query_handler&.mirror_batches_dropped?
|
|
937
|
+
@materialized.preserve_transcripts
|
|
938
|
+
else
|
|
939
|
+
@materialized.cleanup
|
|
940
|
+
end
|
|
899
941
|
@materialized = nil
|
|
900
942
|
end
|
|
901
943
|
end
|
|
@@ -935,7 +977,7 @@ module ClaudeAgentSDK
|
|
|
935
977
|
|
|
936
978
|
# Extract exclude_dynamic_sections from preset system prompt for the
|
|
937
979
|
# initialize request (older CLIs ignore unknown initialize fields)
|
|
938
|
-
exclude_dynamic_sections = extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
980
|
+
exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
939
981
|
|
|
940
982
|
# Create Query handler
|
|
941
983
|
@query_handler = Query.new(
|
|
@@ -972,22 +1014,13 @@ module ClaudeAgentSDK
|
|
|
972
1014
|
# yielding deadlocked connect — and serialized Hash messages with
|
|
973
1015
|
# to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
|
|
974
1016
|
# and is tracked on the Query so close() stops it. Stream errors are
|
|
975
|
-
#
|
|
976
|
-
#
|
|
1017
|
+
# swallowed-with-warn by stream_input (Python parity) — they don't
|
|
1018
|
+
# abort connect, and observers are NOT notified (the documented
|
|
1019
|
+
# Observer#on_error contract; notifying a swallowed error would mark
|
|
1020
|
+
# a still-live OTel trace as failed). Same behavior as query()'s
|
|
1021
|
+
# streaming path.
|
|
977
1022
|
observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
|
|
978
|
-
|
|
979
|
-
@query_handler.spawn_task { @query_handler.stream_input(notifying) }
|
|
980
|
-
end
|
|
981
|
-
end
|
|
982
|
-
|
|
983
|
-
# Wrap a stream so a raising user enumerator fires on_error exactly once
|
|
984
|
-
# before stream_input's swallow-with-warn handling takes over.
|
|
985
|
-
def error_notifying_stream(stream)
|
|
986
|
-
Enumerator.new do |yielder|
|
|
987
|
-
stream.each { |message| yielder << message }
|
|
988
|
-
rescue StandardError => e
|
|
989
|
-
notify_error(e)
|
|
990
|
-
raise
|
|
1023
|
+
@query_handler.spawn_task { @query_handler.stream_input(observed) }
|
|
991
1024
|
end
|
|
992
1025
|
end
|
|
993
1026
|
|
|
@@ -1058,20 +1091,6 @@ module ClaudeAgentSDK
|
|
|
1058
1091
|
internal_hooks
|
|
1059
1092
|
end
|
|
1060
1093
|
|
|
1061
|
-
def extract_exclude_dynamic_sections(system_prompt)
|
|
1062
|
-
if system_prompt.is_a?(SystemPromptPreset)
|
|
1063
|
-
eds = system_prompt.exclude_dynamic_sections
|
|
1064
|
-
return eds if [true, false].include?(eds)
|
|
1065
|
-
elsif system_prompt.is_a?(Hash)
|
|
1066
|
-
type = system_prompt[:type] || system_prompt['type']
|
|
1067
|
-
if type == 'preset'
|
|
1068
|
-
eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
|
|
1069
|
-
return eds if [true, false].include?(eds)
|
|
1070
|
-
end
|
|
1071
|
-
end
|
|
1072
|
-
nil
|
|
1073
|
-
end
|
|
1074
|
-
|
|
1075
1094
|
def writeln(string)
|
|
1076
1095
|
write string.end_with?("\n") ? string : "#{string}\n"
|
|
1077
1096
|
end
|