claude-agent-sdk 0.33.0 → 0.34.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 +81 -0
- data/README.md +1 -1
- data/docs/cli-installer.md +17 -9
- data/docs/client.md +1 -1
- data/docs/configuration.md +5 -5
- data/docs/errors.md +4 -3
- data/docs/mcp-servers.md +22 -0
- data/docs/observability.md +6 -0
- data/docs/rails.md +2 -0
- data/docs/sessions.md +66 -15
- data/lib/claude_agent_sdk/cli_installer.rb +34 -18
- data/lib/claude_agent_sdk/command_builder.rb +11 -3
- data/lib/claude_agent_sdk/configuration.rb +54 -2
- data/lib/claude_agent_sdk/errors.rb +11 -3
- data/lib/claude_agent_sdk/fiber_boundary.rb +42 -3
- data/lib/claude_agent_sdk/instrumentation/otel.rb +21 -2
- data/lib/claude_agent_sdk/query.rb +146 -62
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +56 -4
- data/lib/claude_agent_sdk/session_mutations.rb +41 -16
- data/lib/claude_agent_sdk/session_resume.rb +112 -39
- data/lib/claude_agent_sdk/session_store.rb +19 -3
- data/lib/claude_agent_sdk/session_summary.rb +5 -5
- data/lib/claude_agent_sdk/sessions.rb +123 -55
- data/lib/claude_agent_sdk/streaming.rb +0 -8
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +319 -54
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +13 -3
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +77 -18
- data/lib/claude_agent_sdk/types.rb +130 -36
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +49 -44
- metadata +16 -10
|
@@ -61,6 +61,8 @@ module ClaudeAgentSDK
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def self.ruby_type_to_json_schema(type)
|
|
64
|
+
# Class#=== matches instances, not the class object used in { id: Integer }.
|
|
65
|
+
type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
|
|
64
66
|
case type
|
|
65
67
|
when :string, String then { type: 'string' }
|
|
66
68
|
when :integer, Integer then { type: 'integer' }
|
|
@@ -70,6 +72,24 @@ module ClaudeAgentSDK
|
|
|
70
72
|
end
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
# Internal: call a tool handler, reporting SystemExit / SignalException
|
|
76
|
+
# (Interrupt included) as an ordinary handler failure — re-raised as a
|
|
77
|
+
# RuntimeError (#cause holds the original) that both tools/call dispatch
|
|
78
|
+
# boundaries turn into an in-band isError result, so the pending control
|
|
79
|
+
# response is always written. Must run INSIDE the FiberBoundary.invoke
|
|
80
|
+
# block: a worker thread that dies with SystemExit has it re-raised by
|
|
81
|
+
# Ruby on the MAIN thread, tearing down the reactor, while the dispatcher
|
|
82
|
+
# only sees Async::Stop — a rescue after the hop cannot catch it in
|
|
83
|
+
# :thread mode. A callback_wrapper therefore observes the RuntimeError.
|
|
84
|
+
# Deliberately not `rescue Exception`: cancellation (Async::Stop, and
|
|
85
|
+
# InlineCancellation at an :inline suspension point) must propagate.
|
|
86
|
+
# @api private
|
|
87
|
+
def self.call_tool_handler(handler, arguments)
|
|
88
|
+
handler.call(arguments)
|
|
89
|
+
rescue SystemExit, SignalException => e
|
|
90
|
+
raise e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
73
93
|
# SDK MCP Server - wraps official MCP::Server with block-based API
|
|
74
94
|
#
|
|
75
95
|
# Unlike external MCP servers that run as separate processes, SDK MCP servers
|
|
@@ -79,6 +99,19 @@ module ClaudeAgentSDK
|
|
|
79
99
|
# This class wraps the official MCP Ruby SDK and provides a simpler block-based
|
|
80
100
|
# API for defining tools, resources, and prompts.
|
|
81
101
|
class SdkMcpServer
|
|
102
|
+
# The gem validates arguments before injecting its server_context keyword.
|
|
103
|
+
# Guard actual keys here, independent of schema composition/$ref support,
|
|
104
|
+
# and retain this guard even when schema validation falls back to permissive.
|
|
105
|
+
class ToolInputSchema < MCP::Tool::InputSchema
|
|
106
|
+
def validate_arguments(arguments)
|
|
107
|
+
if arguments.is_a?(Hash) && (arguments.key?(:server_context) || arguments.key?('server_context'))
|
|
108
|
+
raise ValidationError, "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
82
115
|
attr_reader :name, :version, :tools, :resources, :prompts, :mcp_server
|
|
83
116
|
|
|
84
117
|
# Default for where user handlers run when this server is invoked
|
|
@@ -257,7 +290,7 @@ module ClaudeAgentSDK
|
|
|
257
290
|
# AR/PG); in :inline mode it runs in place on the reactor fiber.
|
|
258
291
|
scheduling, wrapper = effective_callback_dispatch
|
|
259
292
|
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
260
|
-
tool.handler
|
|
293
|
+
ClaudeAgentSDK.call_tool_handler(tool.handler, arguments)
|
|
261
294
|
end
|
|
262
295
|
|
|
263
296
|
# Guard before flexible_fetch: it raises on non-Hash inputs.
|
|
@@ -376,6 +409,15 @@ module ClaudeAgentSDK
|
|
|
376
409
|
# mode at call time — same pattern as prompt classes.
|
|
377
410
|
sdk_server = self
|
|
378
411
|
tools.map do |tool_def|
|
|
412
|
+
# The gem injects server_context AFTER expanding the tool arguments,
|
|
413
|
+
# overwriting a user value before our call method can recover it.
|
|
414
|
+
# Check at registration (including raw SdkMcpTool definitions), not in
|
|
415
|
+
# input_schema_value's permissive schema-error fallback.
|
|
416
|
+
schema = ClaudeAgentSDK.normalize_tool_schema(tool_def.input_schema)
|
|
417
|
+
if schema[:properties]&.key?(:server_context)
|
|
418
|
+
raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
|
|
419
|
+
end
|
|
420
|
+
|
|
379
421
|
# Create a new class that extends MCP::Tool
|
|
380
422
|
Class.new(MCP::Tool) do
|
|
381
423
|
@tool_def = tool_def
|
|
@@ -407,11 +449,11 @@ module ClaudeAgentSDK
|
|
|
407
449
|
schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
|
|
408
450
|
schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
|
|
409
451
|
begin
|
|
410
|
-
|
|
452
|
+
ToolInputSchema.new(schema)
|
|
411
453
|
rescue ArgumentError => e
|
|
412
454
|
warn "Claude SDK: tool '#{@tool_def.name}' schema not draft4-compatible " \
|
|
413
455
|
"(#{e.message.lines.first&.strip}); argument validation disabled for this tool"
|
|
414
|
-
|
|
456
|
+
ToolInputSchema.new({ type: 'object', properties: {} })
|
|
415
457
|
end
|
|
416
458
|
end
|
|
417
459
|
end
|
|
@@ -434,7 +476,7 @@ module ClaudeAgentSDK
|
|
|
434
476
|
# the Fiber scheduler; :inline runs in place on the reactor.
|
|
435
477
|
scheduling, wrapper = @sdk_server.effective_callback_dispatch
|
|
436
478
|
result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
|
|
437
|
-
@tool_def.handler
|
|
479
|
+
ClaudeAgentSDK.call_tool_handler(@tool_def.handler, args)
|
|
438
480
|
end
|
|
439
481
|
|
|
440
482
|
# Guard BEFORE flexible_fetch: on a non-Hash it raises
|
|
@@ -453,6 +495,16 @@ module ClaudeAgentSDK
|
|
|
453
495
|
error: !!is_error,
|
|
454
496
|
structured_content: structured_content
|
|
455
497
|
)
|
|
498
|
+
rescue StandardError => e
|
|
499
|
+
# Report handler failures in-band HERE rather than letting them
|
|
500
|
+
# reach the gem: mcp >= 1.2 deliberately drops e.message from
|
|
501
|
+
# its "Internal error calling tool X" wrapper (CWE-209), which
|
|
502
|
+
# would hide the text the model needs to self-correct. Bare
|
|
503
|
+
# e.message like Python's str(e) and #call_tool — no prefix.
|
|
504
|
+
# Nothing gem-internal can be swallowed here today: handlers get
|
|
505
|
+
# no server_context, so MCP::CancelledError never originates
|
|
506
|
+
# inside this method. Revisit if cancellation is ever plumbed in.
|
|
507
|
+
MCP::Tool::Response.new([{ type: 'text', text: e.message }], error: true)
|
|
456
508
|
end
|
|
457
509
|
end
|
|
458
510
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require 'fileutils'
|
|
6
|
+
require 'tempfile'
|
|
6
7
|
require_relative 'sessions'
|
|
7
8
|
require_relative 'session_store'
|
|
8
9
|
|
|
@@ -38,8 +39,7 @@ module ClaudeAgentSDK
|
|
|
38
39
|
stripped = title.strip
|
|
39
40
|
raise ArgumentError, 'title must be non-empty' if stripped.empty?
|
|
40
41
|
|
|
41
|
-
data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id }
|
|
42
|
-
space_size: 0)}\n"
|
|
42
|
+
data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n"
|
|
43
43
|
|
|
44
44
|
append_to_session(session_id, data, directory)
|
|
45
45
|
end
|
|
@@ -64,8 +64,7 @@ module ClaudeAgentSDK
|
|
|
64
64
|
tag = sanitized
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id }
|
|
68
|
-
space_size: 0)}\n"
|
|
67
|
+
data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n"
|
|
69
68
|
|
|
70
69
|
append_to_session(session_id, data, directory)
|
|
71
70
|
end
|
|
@@ -137,17 +136,14 @@ module ClaudeAgentSDK
|
|
|
137
136
|
)
|
|
138
137
|
|
|
139
138
|
fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
# Stage beside the destination, outside the *.jsonl browsing glob. A hard
|
|
140
|
+
# link publishes the closed, complete file atomically without overwriting
|
|
141
|
+
# an existing UUID (rename would replace it). Tempfile owns only the
|
|
142
|
+
# staging name, so failure cleanup never removes somebody else's session.
|
|
143
|
+
Tempfile.create(['.claude-fork-', '.tmp'], project_dir) do |io|
|
|
144
144
|
io.write("#{lines.join("\n")}\n")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
io.close
|
|
148
|
-
else
|
|
149
|
-
IO.for_fd(fd).close rescue nil # rubocop:disable Style/RescueModifier
|
|
150
|
-
end
|
|
145
|
+
io.close
|
|
146
|
+
File.link(io.path, fork_path)
|
|
151
147
|
end
|
|
152
148
|
|
|
153
149
|
ForkSessionResult.new(session_id: forked_session_id)
|
|
@@ -161,6 +157,7 @@ module ClaudeAgentSDK
|
|
|
161
157
|
# by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
|
|
162
158
|
#
|
|
163
159
|
# @raise [ArgumentError] if session_id is invalid or title is empty
|
|
160
|
+
# @raise [Errno::ENOENT] if the session is not found in the store
|
|
164
161
|
def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
|
|
165
162
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
166
163
|
|
|
@@ -168,6 +165,7 @@ module ClaudeAgentSDK
|
|
|
168
165
|
raise ArgumentError, 'title must be non-empty' if stripped.empty?
|
|
169
166
|
|
|
170
167
|
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
168
|
+
ensure_store_session_exists(session_store, key)
|
|
171
169
|
session_store.append(key, [{
|
|
172
170
|
'type' => 'custom-title',
|
|
173
171
|
'customTitle' => stripped,
|
|
@@ -183,6 +181,7 @@ module ClaudeAgentSDK
|
|
|
183
181
|
# Unicode-sanitized before storing.
|
|
184
182
|
#
|
|
185
183
|
# @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
|
|
184
|
+
# @raise [Errno::ENOENT] if the session is not found in the store
|
|
186
185
|
def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
|
|
187
186
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
188
187
|
|
|
@@ -194,6 +193,7 @@ module ClaudeAgentSDK
|
|
|
194
193
|
end
|
|
195
194
|
|
|
196
195
|
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
196
|
+
ensure_store_session_exists(session_store, key)
|
|
197
197
|
session_store.append(key, [{
|
|
198
198
|
'type' => 'tag',
|
|
199
199
|
'tag' => tag || '',
|
|
@@ -253,6 +253,27 @@ module ClaudeAgentSDK
|
|
|
253
253
|
|
|
254
254
|
# -- Private helpers --
|
|
255
255
|
|
|
256
|
+
# Raise Errno::ENOENT (as the disk counterparts and fork_session_via_store
|
|
257
|
+
# do) unless the store holds entries for +key+. Without this probe, a
|
|
258
|
+
# rename/tag of a typo'd or stale id APPENDED metadata to a never-written
|
|
259
|
+
# key, creating a phantom session — permanent on WORM/append-only stores.
|
|
260
|
+
#
|
|
261
|
+
# #load is the probe because it is the only exact per-session existence
|
|
262
|
+
# check the contract offers: it is required, and returns nil for a key
|
|
263
|
+
# that was never written. The optional methods don't fit: list_subkeys
|
|
264
|
+
# returns [] for "no subagents" and "no session" alike,
|
|
265
|
+
# list_session_summaries is an advisory sidecar that may be stale, and
|
|
266
|
+
# list_sessions scans the whole project (no cheaper than one load in the
|
|
267
|
+
# reference adapters).
|
|
268
|
+
#
|
|
269
|
+
# Check-then-act: a concurrent delete between this probe and the append
|
|
270
|
+
# can still recreate the key. That window is inherent to the store API
|
|
271
|
+
# (there is no conditional append), so no locking is attempted.
|
|
272
|
+
def ensure_store_session_exists(session_store, key)
|
|
273
|
+
entries = session_store.load(key)
|
|
274
|
+
raise Errno::ENOENT, "Session #{key['session_id']} not found" if entries.nil? || entries.empty?
|
|
275
|
+
end
|
|
276
|
+
|
|
256
277
|
# Locate the JSONL file for a session and return [file_path, project_dir].
|
|
257
278
|
def find_session_file_with_dir(session_id, directory)
|
|
258
279
|
file_name = "#{session_id}.jsonl"
|
|
@@ -602,7 +623,10 @@ module ClaudeAgentSDK
|
|
|
602
623
|
File.open(path, File::WRONLY | File::APPEND) do |file|
|
|
603
624
|
return false if file.stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate
|
|
604
625
|
|
|
605
|
-
|
|
626
|
+
# The final JSONL record need not have a newline (or may be truncated).
|
|
627
|
+
# Append the boundary and metadata together, without a racy read/check
|
|
628
|
+
# or requiring read access. Readers already ignore empty lines.
|
|
629
|
+
file.write("\n#{data}")
|
|
606
630
|
true
|
|
607
631
|
end
|
|
608
632
|
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
@@ -644,6 +668,7 @@ module ClaudeAgentSDK
|
|
|
644
668
|
:parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
|
|
645
669
|
:append_to_session, :append_to_session_in_directory,
|
|
646
670
|
:append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
|
|
647
|
-
:iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries
|
|
671
|
+
:iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries,
|
|
672
|
+
:ensure_store_session_exists
|
|
648
673
|
end
|
|
649
674
|
end
|
|
@@ -138,18 +138,18 @@ module ClaudeAgentSDK
|
|
|
138
138
|
# prevent traversal and match every other resume path.
|
|
139
139
|
return nil unless options.resume.match?(Sessions::UUID_RE)
|
|
140
140
|
|
|
141
|
-
load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper)
|
|
141
|
+
encode_candidate(load_candidate(store, project_key, options.resume, timeout_s, scheduling, wrapper))
|
|
142
142
|
else
|
|
143
143
|
resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
|
|
144
144
|
end
|
|
145
145
|
return nil if resolved.nil?
|
|
146
146
|
|
|
147
|
-
session_id,
|
|
147
|
+
session_id, lines = resolved
|
|
148
148
|
tmp_base = Dir.mktmpdir('claude-resume-')
|
|
149
149
|
begin
|
|
150
150
|
project_dir = File.join(tmp_base, 'projects', project_key)
|
|
151
151
|
FileUtils.mkdir_p(project_dir)
|
|
152
|
-
write_jsonl(File.join(project_dir, "#{session_id}.jsonl"),
|
|
152
|
+
write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), lines)
|
|
153
153
|
|
|
154
154
|
# The subprocess runs with CLAUDE_CONFIG_DIR=tmp_base; copy auth config
|
|
155
155
|
# so it can authenticate. Missing files are fine (API-key auth, etc.).
|
|
@@ -171,6 +171,7 @@ module ClaudeAgentSDK
|
|
|
171
171
|
# -- Helpers --
|
|
172
172
|
|
|
173
173
|
# Load entries for session_id; return [session_id, entries] or nil if empty.
|
|
174
|
+
# Callers pass the result through encode_candidate before writing.
|
|
174
175
|
def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
|
|
175
176
|
entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
|
|
176
177
|
store.load('project_key' => project_key, 'session_id' => session_id)
|
|
@@ -192,7 +193,7 @@ module ClaudeAgentSDK
|
|
|
192
193
|
|
|
193
194
|
sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
|
|
194
195
|
|
|
195
|
-
sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
|
|
196
|
+
sessions.sort_by { |s| -Sessions.sortable_mtime(s['mtime']) }.each do |cand|
|
|
196
197
|
sid = cand['session_id']
|
|
197
198
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
198
199
|
# Skip known sidechains without downloading their transcript: the
|
|
@@ -203,14 +204,62 @@ module ClaudeAgentSDK
|
|
|
203
204
|
loaded = load_candidate(store, project_key, sid, timeout_s, scheduling, wrapper)
|
|
204
205
|
next if loaded.nil?
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
next if
|
|
207
|
+
encoded = encode_candidate(loaded)
|
|
208
|
+
next if encoded.nil?
|
|
208
209
|
|
|
209
|
-
|
|
210
|
+
# Classify from the first entry that is actually written (the first
|
|
211
|
+
# surviving object), not the raw head: a poisoned or non-Hash first
|
|
212
|
+
# entry would otherwise hide the isSidechain flag the rest carry and
|
|
213
|
+
# --continue would resume a subagent. Same rule as the disk reader.
|
|
214
|
+
head = encoded[2]
|
|
215
|
+
next if head && head['isSidechain'] == true
|
|
216
|
+
|
|
217
|
+
return encoded
|
|
210
218
|
end
|
|
211
219
|
nil
|
|
212
220
|
end
|
|
213
221
|
|
|
222
|
+
# [session_id, entries] -> [session_id, jsonl_lines, head], or nil when no
|
|
223
|
+
# entry survives encoding; head is the first surviving Hash entry (nil if
|
|
224
|
+
# none). Encoding happens BEFORE the temp dir exists so a session whose
|
|
225
|
+
# entries are all unusable behaves exactly like an empty one: --resume
|
|
226
|
+
# falls through to the normal spawn path and --continue moves on to the
|
|
227
|
+
# next candidate.
|
|
228
|
+
def encode_candidate(loaded)
|
|
229
|
+
return nil if loaded.nil?
|
|
230
|
+
|
|
231
|
+
session_id, entries = loaded
|
|
232
|
+
what = "session #{session_id}"
|
|
233
|
+
head = nil
|
|
234
|
+
lines = entries.filter_map do |entry|
|
|
235
|
+
line = encode_entry(entry, what)
|
|
236
|
+
head ||= entry if line && entry.is_a?(Hash)
|
|
237
|
+
line
|
|
238
|
+
end
|
|
239
|
+
lines.empty? ? nil : [session_id, lines, head]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Encode entries as JSON lines, dropping unserializable ones (see encode_entry).
|
|
243
|
+
def encode_jsonl_lines(entries, what)
|
|
244
|
+
entries.filter_map { |entry| encode_entry(entry, what) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Encode one store entry as a compact JSON line, or nil (with a warning
|
|
248
|
+
# naming its uuid when it has one) if it cannot be serialized:
|
|
249
|
+
# NaN/Infinity, invalid UTF-8, circular or over-deep nesting —
|
|
250
|
+
# JSON::NestingError is a ParserError, hence the JSONError rescue. Entries
|
|
251
|
+
# are opaque adapter pass-through, so one poisoned entry must not abort
|
|
252
|
+
# the whole resume: like an unusable sidecar on the disk side, an unusable
|
|
253
|
+
# entry is treated as absent.
|
|
254
|
+
def encode_entry(entry, what)
|
|
255
|
+
JSON.generate(entry)
|
|
256
|
+
rescue JSON::JSONError => e
|
|
257
|
+
uuid = entry.is_a?(Hash) ? entry['uuid'] : nil
|
|
258
|
+
warn "Claude SDK: [SessionStore] resume: skipping unserializable entry#{" uuid=#{uuid.inspect}" if uuid} " \
|
|
259
|
+
"in #{what} (#{e.class}: #{e.message})"
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
214
263
|
# session_id => true for sessions the summary sidecar marks as sidechains;
|
|
215
264
|
# nil when the store doesn't implement list_session_summaries or the call
|
|
216
265
|
# fails (callers then fall back to checking each full load). The per-load
|
|
@@ -231,22 +280,6 @@ module ClaudeAgentSDK
|
|
|
231
280
|
nil
|
|
232
281
|
end
|
|
233
282
|
|
|
234
|
-
# Adapters contractually report mtime as an epoch-ms Numeric (the
|
|
235
|
-
# conformance suite asserts it), but SQL timestamps naturally arrive as
|
|
236
|
-
# ISO-8601 Strings through JSON. Unary minus on a String is String#-@
|
|
237
|
-
# (frozen-string dedup), so String mtimes sorted lexicographically
|
|
238
|
-
# ASCENDING — --continue silently resumed the OLDEST session — and mixed
|
|
239
|
-
# Integer/String lists raised a bare ArgumentError. Coerce defensively:
|
|
240
|
-
# numeric strings and ISO-8601 both order correctly; anything else sorts
|
|
241
|
-
# last rather than crashing resume.
|
|
242
|
-
def sortable_mtime(value)
|
|
243
|
-
case value
|
|
244
|
-
when Numeric then value
|
|
245
|
-
when String then Float(value, exception: false) || Sessions.parse_iso_timestamp_ms(value) || 0
|
|
246
|
-
else 0
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
283
|
# Run a store call (user code) on a plain thread bounded by timeout_s,
|
|
251
284
|
# re-raising failures/timeouts as RuntimeError with context. The thread hop
|
|
252
285
|
# (the default for FiberBoundary with a timeout) both keeps the async
|
|
@@ -270,12 +303,13 @@ module ClaudeAgentSDK
|
|
|
270
303
|
raise "#{what} failed during resume materialization: #{e}"
|
|
271
304
|
end
|
|
272
305
|
|
|
273
|
-
#
|
|
274
|
-
|
|
306
|
+
# Write pre-encoded JSON lines (see encode_jsonl_lines), one per line,
|
|
307
|
+
# mode 0600.
|
|
308
|
+
def write_jsonl(path, lines)
|
|
275
309
|
FileUtils.mkdir_p(File.dirname(path))
|
|
276
310
|
File.open(path, 'w') do |f|
|
|
277
|
-
|
|
278
|
-
f.write(
|
|
311
|
+
lines.each do |line|
|
|
312
|
+
f.write(line)
|
|
279
313
|
f.write("\n")
|
|
280
314
|
end
|
|
281
315
|
end
|
|
@@ -291,14 +325,20 @@ module ClaudeAgentSDK
|
|
|
291
325
|
# cowork_settings.json live under the config dir (default ~/.claude/), while
|
|
292
326
|
# .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
|
|
293
327
|
# ~/.claude.json (NOT ~/.claude/.claude.json).
|
|
328
|
+
#
|
|
329
|
+
# Without a usable home (see .home_dir) the home-relative sources are
|
|
330
|
+
# skipped like missing files: they cannot exist, and raising here aborted
|
|
331
|
+
# every store-backed resume on a HOME-less host — even API-key auth,
|
|
332
|
+
# which needs none of them.
|
|
294
333
|
def copy_auth_files(tmp_base, opt_env)
|
|
295
334
|
caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
|
|
296
|
-
|
|
335
|
+
home = caller_config_dir ? nil : home_dir
|
|
336
|
+
source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
|
|
297
337
|
|
|
298
338
|
# read_if_present returns raw bytes; the credentials path parses and
|
|
299
339
|
# re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes
|
|
300
340
|
# simply fail to parse and get written through, as before).
|
|
301
|
-
creds_bytes = read_if_present(File.join(source_config_dir, '.credentials.json'))
|
|
341
|
+
creds_bytes = source_config_dir && read_if_present(File.join(source_config_dir, '.credentials.json'))
|
|
302
342
|
creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8)
|
|
303
343
|
|
|
304
344
|
# macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting
|
|
@@ -313,8 +353,8 @@ module ClaudeAgentSDK
|
|
|
313
353
|
|
|
314
354
|
write_redacted_credentials(creds_json, File.join(tmp_base, '.credentials.json'))
|
|
315
355
|
|
|
316
|
-
|
|
317
|
-
copy_if_present(
|
|
356
|
+
claude_json_dir = caller_config_dir || home
|
|
357
|
+
copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json')) if claude_json_dir
|
|
318
358
|
|
|
319
359
|
# User settings carry apiKeyHelper (a fourth auth mechanism alongside
|
|
320
360
|
# .credentials.json / Keychain / env vars) plus the user's env, hooks and
|
|
@@ -323,6 +363,8 @@ module ClaudeAgentSDK
|
|
|
323
363
|
# cowork_settings.json is the alternate filename the CLI reads in
|
|
324
364
|
# cowork-plugins mode. Both pass through strip_settings_for_resume so
|
|
325
365
|
# plugin declarations don't reconcile against the empty tmp_base cache.
|
|
366
|
+
return unless source_config_dir
|
|
367
|
+
|
|
326
368
|
transform = ->(content) { strip_settings_for_resume(content) }
|
|
327
369
|
SEEDED_SETTINGS_FILES.each do |name|
|
|
328
370
|
copy_if_present(File.join(source_config_dir, name), File.join(tmp_base, name), transform)
|
|
@@ -565,22 +607,40 @@ module ClaudeAgentSDK
|
|
|
565
607
|
metadata, transcript = Sessions.split_agent_metadata(entries)
|
|
566
608
|
sub_file = File.join(session_dir, "#{subpath}.jsonl")
|
|
567
609
|
|
|
568
|
-
|
|
610
|
+
lines = encode_jsonl_lines(transcript, "subpath #{subpath}")
|
|
611
|
+
write_jsonl(sub_file, lines) unless lines.empty?
|
|
569
612
|
|
|
570
613
|
return if metadata.nil?
|
|
571
614
|
|
|
572
615
|
# Strip the synthetic type field.
|
|
573
|
-
|
|
616
|
+
meta_json = encode_agent_metadata(metadata.except('type'), subpath)
|
|
617
|
+
return if meta_json.nil?
|
|
618
|
+
|
|
574
619
|
meta_file = Sessions.agent_metadata_sidecar_path(sub_file)
|
|
575
620
|
FileUtils.mkdir_p(File.dirname(meta_file))
|
|
576
|
-
File.write(meta_file,
|
|
621
|
+
File.write(meta_file, meta_json)
|
|
577
622
|
chmod_owner_only(meta_file)
|
|
578
623
|
end
|
|
579
624
|
|
|
580
|
-
#
|
|
581
|
-
#
|
|
625
|
+
# An unserializable metadata sidecar is skipped (unusable = absent, as on
|
|
626
|
+
# the disk side) rather than aborting the resume.
|
|
627
|
+
def encode_agent_metadata(meta_content, subpath)
|
|
628
|
+
JSON.generate(meta_content)
|
|
629
|
+
rescue JSON::JSONError => e
|
|
630
|
+
warn "Claude SDK: [SessionStore] resume: skipping unserializable agent metadata " \
|
|
631
|
+
"for subpath #{subpath} (#{e.class}: #{e.message})"
|
|
632
|
+
nil
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Reject subpaths that are not Strings, empty, absolute, drive/UNC-prefixed,
|
|
636
|
+
# contain "." or ".." components or a NUL byte, or escape session_dir after
|
|
637
|
+
# resolution. A non-String subkey (Symbol, Integer) is an adapter contract
|
|
638
|
+
# violation: reject it like any other unsafe subkey (skip that entry)
|
|
639
|
+
# rather than guess at a coercion — calling String methods on it used to
|
|
640
|
+
# raise NoMethodError and abort the whole resume.
|
|
582
641
|
def safe_subpath?(subpath, session_dir)
|
|
583
|
-
return false
|
|
642
|
+
return false unless subpath.is_a?(String)
|
|
643
|
+
return false if subpath.empty?
|
|
584
644
|
return false if subpath.start_with?('/', '\\')
|
|
585
645
|
return false if subpath.match?(/\A[a-zA-Z]:/) # drive-prefixed (C:foo) / UNC
|
|
586
646
|
return false if subpath.split(%r{[\\/]}).any? { |part| ['.', '..'].include?(part) }
|
|
@@ -698,11 +758,24 @@ module ClaudeAgentSDK
|
|
|
698
758
|
value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
|
|
699
759
|
end
|
|
700
760
|
|
|
701
|
-
|
|
761
|
+
# The parent's home directory, or nil when none is usable. Dir.home raises
|
|
762
|
+
# ArgumentError when HOME is unset and the uid has no passwd entry (docker
|
|
763
|
+
# --user in a minimal image), and returns an empty or relative HOME
|
|
764
|
+
# verbatim — reading under "" or a cwd-relative path would seed files the
|
|
765
|
+
# CLI never looks at. SubprocessCLITransport#home_dir applies the same rule.
|
|
766
|
+
def home_dir
|
|
767
|
+
home = Dir.home
|
|
768
|
+
home if File.absolute_path?(home)
|
|
769
|
+
rescue ArgumentError
|
|
770
|
+
nil
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
|
|
702
774
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
703
775
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
704
776
|
:resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
|
|
705
777
|
:strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
|
|
706
|
-
:redacted_credentials
|
|
778
|
+
:redacted_credentials, :home_dir, :encode_candidate, :encode_jsonl_lines, :encode_entry,
|
|
779
|
+
:encode_agent_metadata
|
|
707
780
|
end
|
|
708
781
|
end
|
|
@@ -29,6 +29,9 @@ module ClaudeAgentSDK
|
|
|
29
29
|
# All keys/entries cross the adapter boundary as Hashes with STRING keys:
|
|
30
30
|
# - SessionKey: { 'project_key' => String, 'session_id' => String,
|
|
31
31
|
# 'subpath' => String (optional; omit for the main transcript) }
|
|
32
|
+
# Subagent reads on a store without #list_subkeys synthesize the subpath
|
|
33
|
+
# `subagents/agent-<agent_id>` from the caller's agent_id, which the SDK
|
|
34
|
+
# first restricts to [A-Za-z0-9._-]+ (never '.' or '..').
|
|
32
35
|
# - entries: raw JSONL transcript objects (opaque pass-through blobs)
|
|
33
36
|
# - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
|
|
34
37
|
# - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
|
|
@@ -138,6 +141,8 @@ module ClaudeAgentSDK
|
|
|
138
141
|
return if entries.nil? || entries.empty?
|
|
139
142
|
|
|
140
143
|
@mutex.synchronize do
|
|
144
|
+
key = copy_json(key)
|
|
145
|
+
entries = copy_json(entries)
|
|
141
146
|
k = key_to_string(key)
|
|
142
147
|
(@store[k] ||= []).concat(entries)
|
|
143
148
|
now_ms = next_mtime
|
|
@@ -160,7 +165,7 @@ module ClaudeAgentSDK
|
|
|
160
165
|
def load(key)
|
|
161
166
|
@mutex.synchronize do
|
|
162
167
|
entries = @store[key_to_string(key)]
|
|
163
|
-
entries
|
|
168
|
+
copy_json(entries)
|
|
164
169
|
end
|
|
165
170
|
end
|
|
166
171
|
|
|
@@ -187,7 +192,7 @@ module ClaudeAgentSDK
|
|
|
187
192
|
@summaries.filter_map do |(pk, _sid), summary|
|
|
188
193
|
next unless pk == project_key
|
|
189
194
|
|
|
190
|
-
|
|
195
|
+
copy_json(summary)
|
|
191
196
|
end
|
|
192
197
|
end
|
|
193
198
|
end
|
|
@@ -224,7 +229,7 @@ module ClaudeAgentSDK
|
|
|
224
229
|
|
|
225
230
|
# All entries for a key (empty array if absent).
|
|
226
231
|
def get_entries(key)
|
|
227
|
-
|
|
232
|
+
load(key) || []
|
|
228
233
|
end
|
|
229
234
|
|
|
230
235
|
# Number of stored sessions (main transcripts only).
|
|
@@ -248,6 +253,17 @@ module ClaudeAgentSDK
|
|
|
248
253
|
|
|
249
254
|
private
|
|
250
255
|
|
|
256
|
+
# JSON values are mutable down to their strings. Keep stored snapshots and
|
|
257
|
+
# returned values detached, just like a serialization-backed adapter.
|
|
258
|
+
def copy_json(value)
|
|
259
|
+
case value
|
|
260
|
+
when Hash then value.to_h { |k, v| [k, copy_json(v)] }
|
|
261
|
+
when Array then value.map { |v| copy_json(v) }
|
|
262
|
+
when String then value.dup
|
|
263
|
+
else value
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
251
267
|
# True for a main-transcript key: no subpath, or an empty-string subpath
|
|
252
268
|
# (which key_to_string already folds into the main key).
|
|
253
269
|
def main_transcript_key?(key)
|
|
@@ -126,12 +126,12 @@ module ClaudeAgentSDK
|
|
|
126
126
|
)
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
# Python's `x or None
|
|
129
|
+
# Python's `x or None`, delegating to the disk path's Sessions.presence
|
|
130
|
+
# so both summary paths share ONE definition of blank (whitespace-only
|
|
131
|
+
# included). A local copy that only rejected "" let the store path list
|
|
132
|
+
# a session under an invisible whitespace summary the disk path hid.
|
|
130
133
|
def presence(val)
|
|
131
|
-
|
|
132
|
-
return nil if val.is_a?(String) && val.empty?
|
|
133
|
-
|
|
134
|
-
val
|
|
134
|
+
Sessions.presence(val)
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
# Replicate Sessions#extract_first_prompt_from_head for a single parsed
|