claude-agent-sdk 0.33.1 → 0.35.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 +93 -0
- data/README.md +54 -19
- data/docs/cli-installer.md +40 -9
- data/docs/client.md +27 -18
- 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 +92 -51
- 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 +140 -59
- data/lib/claude_agent_sdk/railtie.rb +94 -0
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +46 -4
- data/lib/claude_agent_sdk/session_mutations.rb +39 -12
- 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/tasks/claude_agent_sdk.rake +30 -0
- data/lib/claude_agent_sdk/tasks.rb +13 -0
- 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 +349 -39
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +53 -44
- data/lib/generators/claude_agent_sdk/install/install_generator.rb +63 -0
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +35 -0
- metadata +29 -13
|
@@ -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
|
|
|
@@ -135,17 +136,14 @@ module ClaudeAgentSDK
|
|
|
135
136
|
)
|
|
136
137
|
|
|
137
138
|
fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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|
|
|
142
144
|
io.write("#{lines.join("\n")}\n")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
io.close
|
|
146
|
-
else
|
|
147
|
-
IO.for_fd(fd).close rescue nil # rubocop:disable Style/RescueModifier
|
|
148
|
-
end
|
|
145
|
+
io.close
|
|
146
|
+
File.link(io.path, fork_path)
|
|
149
147
|
end
|
|
150
148
|
|
|
151
149
|
ForkSessionResult.new(session_id: forked_session_id)
|
|
@@ -159,6 +157,7 @@ module ClaudeAgentSDK
|
|
|
159
157
|
# by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
|
|
160
158
|
#
|
|
161
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
|
|
162
161
|
def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
|
|
163
162
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
164
163
|
|
|
@@ -166,6 +165,7 @@ module ClaudeAgentSDK
|
|
|
166
165
|
raise ArgumentError, 'title must be non-empty' if stripped.empty?
|
|
167
166
|
|
|
168
167
|
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
168
|
+
ensure_store_session_exists(session_store, key)
|
|
169
169
|
session_store.append(key, [{
|
|
170
170
|
'type' => 'custom-title',
|
|
171
171
|
'customTitle' => stripped,
|
|
@@ -181,6 +181,7 @@ module ClaudeAgentSDK
|
|
|
181
181
|
# Unicode-sanitized before storing.
|
|
182
182
|
#
|
|
183
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
|
|
184
185
|
def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
|
|
185
186
|
raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
|
|
186
187
|
|
|
@@ -192,6 +193,7 @@ module ClaudeAgentSDK
|
|
|
192
193
|
end
|
|
193
194
|
|
|
194
195
|
key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
|
|
196
|
+
ensure_store_session_exists(session_store, key)
|
|
195
197
|
session_store.append(key, [{
|
|
196
198
|
'type' => 'tag',
|
|
197
199
|
'tag' => tag || '',
|
|
@@ -251,6 +253,27 @@ module ClaudeAgentSDK
|
|
|
251
253
|
|
|
252
254
|
# -- Private helpers --
|
|
253
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
|
+
|
|
254
277
|
# Locate the JSONL file for a session and return [file_path, project_dir].
|
|
255
278
|
def find_session_file_with_dir(session_id, directory)
|
|
256
279
|
file_name = "#{session_id}.jsonl"
|
|
@@ -600,7 +623,10 @@ module ClaudeAgentSDK
|
|
|
600
623
|
File.open(path, File::WRONLY | File::APPEND) do |file|
|
|
601
624
|
return false if file.stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate
|
|
602
625
|
|
|
603
|
-
|
|
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}")
|
|
604
630
|
true
|
|
605
631
|
end
|
|
606
632
|
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
@@ -642,6 +668,7 @@ module ClaudeAgentSDK
|
|
|
642
668
|
:parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
|
|
643
669
|
:append_to_session, :append_to_session_in_directory,
|
|
644
670
|
:append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
|
|
645
|
-
: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
|
|
646
673
|
end
|
|
647
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
|