claude-agent-sdk 0.35.0 → 0.36.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 +57 -0
- data/README.md +16 -7
- data/docs/cli-installer.md +16 -2
- data/docs/client.md +18 -3
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +22 -0
- data/docs/mcp-servers.md +37 -7
- data/docs/rails.md +3 -4
- data/docs/sessions.md +69 -33
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +38 -8
- data/lib/claude_agent_sdk/deprecation.rb +51 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +111 -2
- data/lib/claude_agent_sdk/option_warnings.rb +0 -2
- data/lib/claude_agent_sdk/query.rb +49 -8
- data/lib/claude_agent_sdk/railtie.rb +14 -3
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +36 -25
- data/lib/claude_agent_sdk/session_mutations.rb +10 -10
- data/lib/claude_agent_sdk/session_resume.rb +19 -24
- data/lib/claude_agent_sdk/session_store.rb +28 -18
- data/lib/claude_agent_sdk/session_summary.rb +8 -3
- data/lib/claude_agent_sdk/sessions.rb +104 -18
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +4 -13
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +1 -1
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +17 -1
- data/lib/claude_agent_sdk/types.rb +2 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +257 -56
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
- metadata +2 -1
|
@@ -96,9 +96,9 @@ module ClaudeAgentSDK
|
|
|
96
96
|
# Build a TranscriptMirrorBatcher for a configured session_store. Shared by
|
|
97
97
|
# both entry points (Client#install_transcript_mirror and the one-shot
|
|
98
98
|
# query()) so projects_dir resolution and the eager/batched threshold choice
|
|
99
|
-
# live in one place. +env+ supplies the CLAUDE_CONFIG_DIR
|
|
100
|
-
# locate the projects dir (already repointed at the temp dir when
|
|
101
|
-
# from a store). Eager flush mode zeroes the buffer thresholds so every
|
|
99
|
+
# live in one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides
|
|
100
|
+
# used to locate the projects dir (already repointed at the temp dir when
|
|
101
|
+
# resuming from a store). Eager flush mode zeroes the buffer thresholds so every
|
|
102
102
|
# transcript_mirror frame triggers a background flush.
|
|
103
103
|
def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper: nil)
|
|
104
104
|
TranscriptMirrorBatcher.new(
|
|
@@ -193,7 +193,10 @@ module ClaudeAgentSDK
|
|
|
193
193
|
|
|
194
194
|
sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
# Same order as the listings (#78): newest first, equal mtimes by
|
|
197
|
+
# session_id — sort_by is unstable, so an mtime-only key let equal
|
|
198
|
+
# mtimes resume whichever session the adapter happened to list first.
|
|
199
|
+
sessions.sort_by { |s| Sessions.listing_sort_key(s['mtime'], s['session_id']) }.each do |cand|
|
|
197
200
|
sid = cand['session_id']
|
|
198
201
|
next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
|
|
199
202
|
# Skip known sidechains without downloading their transcript: the
|
|
@@ -293,8 +296,8 @@ module ClaudeAgentSDK
|
|
|
293
296
|
# thread-hop bound still applies. The session's callback_wrapper
|
|
294
297
|
# composes inside the bound (see FiberBoundary.invoke); a wrapper-raised
|
|
295
298
|
# error surfaces like a store error, with the same context message.
|
|
296
|
-
def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &
|
|
297
|
-
FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &
|
|
299
|
+
def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &)
|
|
300
|
+
FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &)
|
|
298
301
|
rescue FiberBoundary::JoinTimeout
|
|
299
302
|
raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
|
|
300
303
|
rescue RuntimeError
|
|
@@ -326,13 +329,17 @@ module ClaudeAgentSDK
|
|
|
326
329
|
# .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
|
|
327
330
|
# ~/.claude.json (NOT ~/.claude/.claude.json).
|
|
328
331
|
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
332
|
+
# Both are resolved as the CHILD will see them: CLAUDE_CONFIG_DIR via
|
|
333
|
+
# env_value, and "~" as the HOME in options.env when it sets one (see
|
|
334
|
+
# Sessions.home_dir) — seeding from the parent's home would copy another
|
|
335
|
+
# user's credentials and settings than the ones the CLI would have read.
|
|
336
|
+
# Without a usable home the home-relative sources are skipped like
|
|
337
|
+
# missing files: they cannot exist, and raising here aborted every
|
|
338
|
+
# store-backed resume on a HOME-less host — even API-key auth, which
|
|
339
|
+
# needs none of them.
|
|
333
340
|
def copy_auth_files(tmp_base, opt_env)
|
|
334
341
|
caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
|
|
335
|
-
home = caller_config_dir ? nil : home_dir
|
|
342
|
+
home = caller_config_dir ? nil : Sessions.home_dir(opt_env)
|
|
336
343
|
source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
|
|
337
344
|
|
|
338
345
|
# read_if_present returns raw bytes; the credentials path parses and
|
|
@@ -758,24 +765,12 @@ module ClaudeAgentSDK
|
|
|
758
765
|
value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
|
|
759
766
|
end
|
|
760
767
|
|
|
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
768
|
private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
|
|
774
769
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
775
770
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
776
771
|
:resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
|
|
777
772
|
:strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
|
|
778
|
-
:redacted_credentials, :
|
|
773
|
+
:redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry,
|
|
779
774
|
:encode_agent_metadata
|
|
780
775
|
end
|
|
781
776
|
end
|
|
@@ -94,13 +94,13 @@ module ClaudeAgentSDK
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
# List sessions for a project_key as [{ 'session_id', 'mtime' }]. Optional —
|
|
97
|
-
# if unimplemented,
|
|
97
|
+
# if unimplemented, list_sessions(session_store:) raises.
|
|
98
98
|
def list_sessions(_project_key)
|
|
99
99
|
raise NotImplementedError
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
# Return incrementally-maintained summaries for all sessions in one call.
|
|
103
|
-
# Optional — if unimplemented,
|
|
103
|
+
# Optional — if unimplemented, list_sessions(session_store:) falls back to
|
|
104
104
|
# list_sessions + per-session load.
|
|
105
105
|
def list_session_summaries(_project_key)
|
|
106
106
|
raise NotImplementedError
|
|
@@ -396,27 +396,37 @@ module ClaudeAgentSDK
|
|
|
396
396
|
'(checkpoints are local-disk only and would diverge from the mirrored transcript)'
|
|
397
397
|
end
|
|
398
398
|
|
|
399
|
-
# Path to the rel-from base where
|
|
400
|
-
# CLAUDE_CONFIG_DIR
|
|
401
|
-
# Mirrors Sessions#config_dir but
|
|
399
|
+
# Path to the rel-from base where the CLI subprocess writes session
|
|
400
|
+
# transcripts: its CLAUDE_CONFIG_DIR, else ~/.claude under the home the
|
|
401
|
+
# CHILD sees. Mirrors Sessions#config_dir but resolves both through the
|
|
402
|
+
# options.env passed to the subprocess.
|
|
402
403
|
#
|
|
403
404
|
# Presence is detected by KEY, not value: the transport treats an explicit
|
|
404
405
|
# nil value as "unset the var for the child", so the CLI then writes under
|
|
405
406
|
# the default ~/.claude — not under the parent's CLAUDE_CONFIG_DIR. Empty
|
|
406
|
-
# strings get the same treatment (the Node CLI treats "" as unset).
|
|
407
|
+
# strings get the same treatment (the Node CLI treats "" as unset). A HOME
|
|
408
|
+
# in options.env likewise moves that default (see Sessions.home_dir).
|
|
409
|
+
#
|
|
410
|
+
# Returns nil when the default is needed but there is no usable home
|
|
411
|
+
# (#120). This runs at connect for every session with a session_store, so
|
|
412
|
+
# raising (as Sessions.config_dir does) would abort a fresh session over
|
|
413
|
+
# its secondary copy; the TranscriptMirrorBatcher instead reports the
|
|
414
|
+
# frames it cannot key as MirrorErrorMessage.
|
|
407
415
|
def projects_dir(env_override = nil)
|
|
408
|
-
if env_override.respond_to?(:key?) &&
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
416
|
+
override = if env_override.respond_to?(:key?) &&
|
|
417
|
+
(env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
|
|
418
|
+
env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
|
|
419
|
+
else
|
|
420
|
+
ENV.fetch('CLAUDE_CONFIG_DIR', nil)
|
|
421
|
+
end
|
|
422
|
+
override = nil if override.respond_to?(:empty?) && override.empty?
|
|
423
|
+
# NFC like Python's _get_projects_dir(env_override) — a decomposed
|
|
424
|
+
# Unicode override would otherwise mismatch the NFC paths used for
|
|
425
|
+
# the mirror's projects-dir prefix comparison and drop every frame.
|
|
426
|
+
return File.join(override.unicode_normalize(:nfc), 'projects') if override
|
|
427
|
+
|
|
428
|
+
home = Sessions.home_dir(env_override)
|
|
429
|
+
home && File.join(home, '.claude', 'projects').unicode_normalize(:nfc)
|
|
420
430
|
end
|
|
421
431
|
end
|
|
422
432
|
end
|
|
@@ -7,7 +7,7 @@ module ClaudeAgentSDK
|
|
|
7
7
|
# Incremental session-summary derivation for SessionStore adapters.
|
|
8
8
|
#
|
|
9
9
|
# fold_session_summary lets a store maintain a per-session summary sidecar
|
|
10
|
-
# incrementally inside #append so
|
|
10
|
+
# incrementally inside #append so list_sessions(session_store:) can fetch all
|
|
11
11
|
# metadata in a single #list_session_summaries call instead of N per-session
|
|
12
12
|
# #load calls. Every derived field is append-incremental (set-once or
|
|
13
13
|
# last-wins) so adapters never need to re-read previously appended entries.
|
|
@@ -69,8 +69,10 @@ module ClaudeAgentSDK
|
|
|
69
69
|
data['created_at'] = ms if ms
|
|
70
70
|
|
|
71
71
|
unless data.key?('cwd')
|
|
72
|
+
# First non-blank cwd (Sessions.presence: whitespace-only and
|
|
73
|
+
# invalidly encoded count as blank, as on the disk path).
|
|
72
74
|
cwd = entry['cwd']
|
|
73
|
-
data['cwd'] = cwd if cwd.is_a?(String) &&
|
|
75
|
+
data['cwd'] = cwd if cwd.is_a?(String) && presence(cwd)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
fold_first_prompt(data, entry)
|
|
@@ -113,7 +115,10 @@ module ClaudeAgentSDK
|
|
|
113
115
|
SDKSessionInfo.new(
|
|
114
116
|
session_id: entry['session_id'],
|
|
115
117
|
summary: summary,
|
|
116
|
-
|
|
118
|
+
# Integer epoch ms as documented, whatever shape the adapter stamped
|
|
119
|
+
# (ISO String, numeric String, Float, Time) — the value the listing
|
|
120
|
+
# orders by. Python passes entry["mtime"] through raw.
|
|
121
|
+
last_modified: Sessions.epoch_ms_mtime(entry['mtime']),
|
|
117
122
|
# file_size is a JSONL byte count — meaningful only for the local-disk
|
|
118
123
|
# path. Stores have no equivalent.
|
|
119
124
|
file_size: nil,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
5
|
require 'pathname'
|
|
6
|
+
require_relative 'errors'
|
|
6
7
|
require_relative 'session_store'
|
|
7
8
|
require_relative 'session_summary'
|
|
8
9
|
require_relative 'transcript_mirror_batcher'
|
|
@@ -176,11 +177,53 @@ module ClaudeAgentSDK
|
|
|
176
177
|
# Get the Claude config directory (respects CLAUDE_CONFIG_DIR; an empty
|
|
177
178
|
# value is treated as unset, matching the Node CLI and the Python SDK).
|
|
178
179
|
# NFC-normalized on both branches like Python's _get_claude_config_home_dir.
|
|
180
|
+
#
|
|
181
|
+
# @raise [ConfigDirError] when CLAUDE_CONFIG_DIR is unset and there is no
|
|
182
|
+
# usable home directory (see .home_dir) for the default ~/.claude.
|
|
183
|
+
# Python raises too (Path.home() -> RuntimeError); `~` expansion here
|
|
184
|
+
# raised a bare ArgumentError from deep inside every disk session API.
|
|
179
185
|
def config_dir
|
|
180
186
|
dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil)
|
|
181
187
|
return dir.unicode_normalize(:nfc) if dir && !dir.empty?
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
home = home_dir
|
|
190
|
+
unless home
|
|
191
|
+
raise ConfigDirError,
|
|
192
|
+
'Cannot locate the Claude config directory: CLAUDE_CONFIG_DIR is unset and the home directory ' \
|
|
193
|
+
'could not be resolved (HOME is unset, empty or relative, and the user has no passwd entry). ' \
|
|
194
|
+
'Set CLAUDE_CONFIG_DIR to the directory holding your Claude Code data (normally ~/.claude).'
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
File.join(home, '.claude').unicode_normalize(:nfc)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A usable home directory, or nil when there is none. The ONE definition
|
|
201
|
+
# of "home" for the SDK (CLI discovery, disk session APIs, the transcript
|
|
202
|
+
# mirror, store-backed resume seeding).
|
|
203
|
+
#
|
|
204
|
+
# Without +env+, the parent process's home: Dir.home raises ArgumentError
|
|
205
|
+
# when HOME is unset and the uid has no passwd entry (docker --user in a
|
|
206
|
+
# minimal image), and returns an empty or relative HOME verbatim — a path
|
|
207
|
+
# under "" or a cwd-relative dir is not where the user's data lives (and
|
|
208
|
+
# a relative CLI hit would be spawned from options.cwd, i.e. a different
|
|
209
|
+
# file), so both read as "no home".
|
|
210
|
+
#
|
|
211
|
+
# With +env+ (a ClaudeAgentOptions#env Hash), the home the CLI CHILD will
|
|
212
|
+
# see: a HOME key there wins, by key presence like the CLAUDE_CONFIG_DIR
|
|
213
|
+
# override, and must be absolute. An explicit nil (the transport unsets
|
|
214
|
+
# HOME for the child, which then falls back to its passwd entry) is also
|
|
215
|
+
# treated as no home rather than guessing that entry.
|
|
216
|
+
#
|
|
217
|
+
# @api private
|
|
218
|
+
def home_dir(env = nil)
|
|
219
|
+
home = if env.respond_to?(:key?) && (env.key?('HOME') || env.key?(:HOME))
|
|
220
|
+
env['HOME'] || env[:HOME]
|
|
221
|
+
else
|
|
222
|
+
Dir.home
|
|
223
|
+
end
|
|
224
|
+
home if home.is_a?(String) && File.absolute_path?(home)
|
|
225
|
+
rescue ArgumentError
|
|
226
|
+
nil
|
|
184
227
|
end
|
|
185
228
|
|
|
186
229
|
# Find the project directory for a given path
|
|
@@ -239,7 +282,11 @@ module ClaudeAgentSDK
|
|
|
239
282
|
# top-level shape can't be checked, and dropping it would regress the
|
|
240
283
|
# common case of a true entry cut by the 64KB window. Unverified blanks
|
|
241
284
|
# cannot clear a previously verified value: they may be nested tool inputs.
|
|
242
|
-
|
|
285
|
+
#
|
|
286
|
+
# +skip_blank+ passes over verified blank values too, for set-once fields
|
|
287
|
+
# the store fold only takes when non-blank (cwd): there a blank entry is
|
|
288
|
+
# absent, not a clearing entry.
|
|
289
|
+
def extract_top_level_string_field(text, key, last: false, skip_blank: false)
|
|
243
290
|
positions = field_match_positions(text, key)
|
|
244
291
|
positions.reverse! if last
|
|
245
292
|
parsed_lines = {}
|
|
@@ -250,9 +297,9 @@ module ClaudeAgentSDK
|
|
|
250
297
|
end
|
|
251
298
|
if entry
|
|
252
299
|
value = entry[key]
|
|
253
|
-
return value if value.is_a?(String)
|
|
300
|
+
return value if value.is_a?(String) && (!skip_blank || presence(value))
|
|
254
301
|
|
|
255
|
-
next # parseable line without a top-level string value: nested/false match
|
|
302
|
+
next # parseable line without a usable top-level string value: nested/false match
|
|
256
303
|
end
|
|
257
304
|
value = extract_json_string_value(text, value_start)
|
|
258
305
|
value = presence(unescape_json_string(value)) if value
|
|
@@ -334,6 +381,8 @@ module ClaudeAgentSDK
|
|
|
334
381
|
# a NoMethodError from deep inside (`123.match?`, `123.empty?`) — and so
|
|
335
382
|
# does an invalidly encoded String, on which the regexp match itself
|
|
336
383
|
# raises ArgumentError ("invalid byte sequence").
|
|
384
|
+
#
|
|
385
|
+
# @api private
|
|
337
386
|
def valid_session_id?(session_id)
|
|
338
387
|
session_id.is_a?(String) && session_id.valid_encoding? && session_id.match?(UUID_RE)
|
|
339
388
|
end
|
|
@@ -352,12 +401,26 @@ module ClaudeAgentSDK
|
|
|
352
401
|
# bare ArgumentError. Coerce defensively wherever an adapter mtime is
|
|
353
402
|
# ordered or compared: numeric strings and ISO-8601 both order
|
|
354
403
|
# correctly; anything else sorts last rather than crashing.
|
|
404
|
+
#
|
|
405
|
+
# A Time (e.g. an ActiveRecord updated_at) counts as its instant in epoch
|
|
406
|
+
# ms. Non-finite values (Infinity, "1e400") also read as 0: they cannot be
|
|
407
|
+
# ordered against real clocks nor reported as epoch ms.
|
|
355
408
|
def sortable_mtime(value)
|
|
356
|
-
case value
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
409
|
+
ms = case value
|
|
410
|
+
when Numeric then value
|
|
411
|
+
when Time then value.to_r * 1000
|
|
412
|
+
when String then Float(value, exception: false) || parse_iso_timestamp_ms(value)
|
|
413
|
+
end
|
|
414
|
+
ms.is_a?(Numeric) && ms.real? && ms.finite? ? ms : 0
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# An adapter mtime as SDKSessionInfo#last_modified promises it: Integer
|
|
418
|
+
# epoch milliseconds. Same coercion as sortable_mtime (so a row reports
|
|
419
|
+
# the value it is ordered by), truncated to whole milliseconds.
|
|
420
|
+
#
|
|
421
|
+
# @api private
|
|
422
|
+
def epoch_ms_mtime(value)
|
|
423
|
+
sortable_mtime(value).to_i
|
|
361
424
|
end
|
|
362
425
|
|
|
363
426
|
# Sort key shared by every session listing, disk and store: newest first,
|
|
@@ -365,6 +428,8 @@ module ClaudeAgentSDK
|
|
|
365
428
|
# the secondary key equal mtimes (coarse adapter clocks, bulk imports)
|
|
366
429
|
# ordered arbitrarily between calls and offset/limit paging could skip or
|
|
367
430
|
# repeat sessions; one key also keeps the two paths in the same order.
|
|
431
|
+
#
|
|
432
|
+
# @api private
|
|
368
433
|
def listing_sort_key(mtime, session_id)
|
|
369
434
|
[-sortable_mtime(mtime), session_id.to_s]
|
|
370
435
|
end
|
|
@@ -495,7 +560,9 @@ module ClaudeAgentSDK
|
|
|
495
560
|
extract_top_level_string_field(head, 'customTitle', last: true)) ||
|
|
496
561
|
presence(extract_top_level_string_field(tail, 'aiTitle', last: true) ||
|
|
497
562
|
extract_top_level_string_field(head, 'aiTitle', last: true))
|
|
498
|
-
|
|
563
|
+
# nil, not '', when there is no prompt — the store path's answer, and
|
|
564
|
+
# Python's (`_extract_first_prompt_from_head(head) or None`).
|
|
565
|
+
first_prompt = presence(extract_first_prompt_from_head(head))
|
|
499
566
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
500
567
|
summary = custom_title ||
|
|
501
568
|
presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
|
|
@@ -528,7 +595,11 @@ module ClaudeAgentSDK
|
|
|
528
595
|
# (SessionSummary.summary_entry_to_sdk_info) reports it.
|
|
529
596
|
git_branch: presence(extract_json_string_field(tail, 'gitBranch', last: true) ||
|
|
530
597
|
extract_json_string_field(head, 'gitBranch', last: false)),
|
|
531
|
-
|
|
598
|
+
# The first non-blank TOP-LEVEL cwd, exactly what the store fold keeps
|
|
599
|
+
# (set-once, blank skipped): taking the first match even when blank
|
|
600
|
+
# fell back to the project path where the store read a later entry's
|
|
601
|
+
# cwd, and the raw scan also matched cwd keys nested in tool inputs.
|
|
602
|
+
cwd: extract_top_level_string_field(head, 'cwd', skip_blank: true) || project_path,
|
|
532
603
|
tag: tag_value,
|
|
533
604
|
created_at: created_at
|
|
534
605
|
)
|
|
@@ -1011,7 +1082,7 @@ module ClaudeAgentSDK
|
|
|
1011
1082
|
# its mtime) rather than aborting the whole listing — matches the disk
|
|
1012
1083
|
# path's per-file rescue and the store path's degrade-the-row contract.
|
|
1013
1084
|
warn "Claude SDK: [SessionStore] gap-fill load failed for session #{sid}: #{e.message}"
|
|
1014
|
-
return SDKSessionInfo.new(session_id: sid, summary: '', last_modified: slot[:mtime])
|
|
1085
|
+
return SDKSessionInfo.new(session_id: sid, summary: '', last_modified: epoch_ms_mtime(slot[:mtime]))
|
|
1015
1086
|
end
|
|
1016
1087
|
return nil if entries.nil? || entries.empty?
|
|
1017
1088
|
|
|
@@ -1288,7 +1359,9 @@ module ClaudeAgentSDK
|
|
|
1288
1359
|
return [] unless File.directory?(projects_dir)
|
|
1289
1360
|
|
|
1290
1361
|
all_sessions = []
|
|
1291
|
-
|
|
1362
|
+
# Sorted: the scan order is deduplicate_sessions' last tiebreak, and
|
|
1363
|
+
# Dir.children returns filesystem order.
|
|
1364
|
+
Dir.children(projects_dir).sort.each do |child|
|
|
1292
1365
|
dir = File.join(projects_dir, child)
|
|
1293
1366
|
next unless File.directory?(dir)
|
|
1294
1367
|
|
|
@@ -1298,15 +1371,26 @@ module ClaudeAgentSDK
|
|
|
1298
1371
|
deduplicate_sessions(all_sessions)
|
|
1299
1372
|
end
|
|
1300
1373
|
|
|
1374
|
+
# One entry per session_id when the same session sits in several project
|
|
1375
|
+
# dirs (copied config dirs, worktrees). The newest last_modified wins; on
|
|
1376
|
+
# equal mtimes the larger file (the more complete copy), and then the
|
|
1377
|
+
# copy scanned first — project dirs in name order for the global listing,
|
|
1378
|
+
# worktrees in `git worktree list` order (main worktree first) for a
|
|
1379
|
+
# directory listing. Python keeps the first copy seen in iterdir() order
|
|
1380
|
+
# (sessions.py _deduplicate_by_session_id), which is arbitrary on a tie.
|
|
1301
1381
|
def deduplicate_sessions(sessions)
|
|
1302
1382
|
by_id = {}
|
|
1303
1383
|
sessions.each do |s|
|
|
1304
1384
|
existing = by_id[s.session_id]
|
|
1305
|
-
by_id[s.session_id] = s if existing.nil? || s
|
|
1385
|
+
by_id[s.session_id] = s if existing.nil? || (dedup_rank(s) <=> dedup_rank(existing)).positive?
|
|
1306
1386
|
end
|
|
1307
1387
|
by_id.values
|
|
1308
1388
|
end
|
|
1309
1389
|
|
|
1390
|
+
def dedup_rank(session)
|
|
1391
|
+
[session.last_modified, session.file_size.to_i]
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1310
1394
|
# Probe git for the worktree list with a hard 5-second cap. A stale
|
|
1311
1395
|
# git lock or hung network mount must not block the listing path
|
|
1312
1396
|
# forever. Stdlib `Timeout.timeout` raises across threads via
|
|
@@ -1544,12 +1628,12 @@ module ClaudeAgentSDK
|
|
|
1544
1628
|
|
|
1545
1629
|
private_class_method :get_session_info_for_directory,
|
|
1546
1630
|
:list_sessions_for_directory, :list_all_sessions,
|
|
1547
|
-
:deduplicate_sessions,
|
|
1631
|
+
:deduplicate_sessions, :dedup_rank,
|
|
1548
1632
|
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1549
1633
|
:collect_agent_files, :parse_jsonl_entries,
|
|
1550
1634
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1551
1635
|
:filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts,
|
|
1552
|
-
:
|
|
1636
|
+
:valid_agent_id?, :sidechain_head?,
|
|
1553
1637
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
1554
1638
|
:derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
|
|
1555
1639
|
:filter_transcript_entries, :entries_to_messages,
|
|
@@ -1557,7 +1641,9 @@ module ClaudeAgentSDK
|
|
|
1557
1641
|
:import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files,
|
|
1558
1642
|
:read_agent_metadata_sidecar, :parent_ids_from_agent_metadata
|
|
1559
1643
|
|
|
1560
|
-
# These remain accessible for SessionMutations:
|
|
1561
|
-
# config_dir, sanitize_path, find_project_dir, detect_worktrees
|
|
1644
|
+
# These remain accessible for SessionMutations / SessionResume:
|
|
1645
|
+
# config_dir, sanitize_path, find_project_dir, detect_worktrees,
|
|
1646
|
+
# valid_session_id? (mutation boundary checks), listing_sort_key
|
|
1647
|
+
# (--continue candidate order)
|
|
1562
1648
|
end
|
|
1563
1649
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
|
-
require 'set'
|
|
6
5
|
require 'timeout'
|
|
7
6
|
require_relative 'transport'
|
|
8
7
|
require_relative 'errors'
|
|
@@ -689,7 +688,7 @@ module ClaudeAgentSDK
|
|
|
689
688
|
# Ignore
|
|
690
689
|
end
|
|
691
690
|
|
|
692
|
-
def read_messages(&
|
|
691
|
+
def read_messages(&)
|
|
693
692
|
return enum_for(:read_messages) unless block_given?
|
|
694
693
|
|
|
695
694
|
raise CLIConnectionError, 'Not connected' unless @process && @stdout
|
|
@@ -1050,18 +1049,10 @@ module ClaudeAgentSDK
|
|
|
1050
1049
|
end
|
|
1051
1050
|
end
|
|
1052
1051
|
|
|
1053
|
-
# The home directory for the well-known install probes, or nil
|
|
1054
|
-
# is usable
|
|
1055
|
-
# has no passwd entry (docker --user in a minimal image), and returns an
|
|
1056
|
-
# empty or relative HOME verbatim — probing under "" or a relative path
|
|
1057
|
-
# would check files that are not where the user's install lives (and a
|
|
1058
|
-
# relative hit would be spawned from options.cwd, i.e. a different file).
|
|
1059
|
-
# SessionResume.home_dir applies the same rule.
|
|
1052
|
+
# The (parent's) home directory for the well-known install probes, or nil
|
|
1053
|
+
# when none is usable — see Sessions.home_dir, the one definition.
|
|
1060
1054
|
def home_dir
|
|
1061
|
-
|
|
1062
|
-
home if File.absolute_path?(home)
|
|
1063
|
-
rescue ArgumentError
|
|
1064
|
-
nil
|
|
1055
|
+
Sessions.home_dir
|
|
1065
1056
|
end
|
|
1066
1057
|
end
|
|
1067
1058
|
end
|
|
@@ -9,11 +9,18 @@ unless Rake::Task.task_defined?('claude_agent_sdk:install_cli')
|
|
|
9
9
|
desc 'Install the Claude Code CLI into vendor/claude: the version this gem is tested with, ' \
|
|
10
10
|
'or CLAUDE_CLI_VERSION=x.y.z / stable / latest'
|
|
11
11
|
task :install_cli do
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
# Install where discovery looks. An explicitly set CLIInstaller.root
|
|
13
|
+
# (config/application.rb, the Rakefile) wins: nil dir means
|
|
14
|
+
# CLIInstaller.default_dir, i.e. <root>/vendor/claude. Otherwise, under
|
|
15
|
+
# Rails, anchor to the app root instead of the process cwd — the same
|
|
16
|
+
# root the Railtie gives discovery at boot. The app is not booted here
|
|
17
|
+
# (no :environment dependency, so this runs in a Docker build without
|
|
18
|
+
# credentials), so that initializer has not run. Resolved when the
|
|
19
|
+
# task runs.
|
|
20
|
+
unless ClaudeAgentSDK::CLIInstaller.root
|
|
21
|
+
root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
|
|
22
|
+
dir = root&.join('vendor', 'claude')&.to_s
|
|
23
|
+
end
|
|
17
24
|
# Not the conventional rake `VERSION`: Rails' own db:migrate uses it and
|
|
18
25
|
# build environments often export it for the app's version or git SHA.
|
|
19
26
|
version = ENV.fetch('CLAUDE_CLI_VERSION', '').strip
|
|
@@ -360,7 +360,7 @@ module ClaudeAgentSDK
|
|
|
360
360
|
# Fetch summaries, asserting on the RAW rows before collapsing into a hash:
|
|
361
361
|
# a store returning one row per append (every historical fold version)
|
|
362
362
|
# would otherwise pass — and then surface duplicate sessions from
|
|
363
|
-
#
|
|
363
|
+
# list_sessions(session_store:).
|
|
364
364
|
def summaries_by_id(store, project, expected_ids, message)
|
|
365
365
|
rows = Array(store.list_session_summaries(project))
|
|
366
366
|
assert_eq(rows.map { |s| s['session_id'] }.sort, expected_ids.sort, message)
|
|
@@ -56,7 +56,9 @@ module ClaudeAgentSDK
|
|
|
56
56
|
MIRROR_APPEND_BACKOFF_S = [0.2, 0.8].freeze
|
|
57
57
|
|
|
58
58
|
# @param store [SessionStore] the adapter to mirror into
|
|
59
|
-
# @param projects_dir [String] base dir for file_path -> SessionKey
|
|
59
|
+
# @param projects_dir [String, nil] base dir for file_path -> SessionKey
|
|
60
|
+
# mapping; nil when it could not be resolved (every frame is then
|
|
61
|
+
# dropped and reported via +on_error+)
|
|
60
62
|
# @param on_error [#call] called as on_error.call(key, message) after a batch
|
|
61
63
|
# exhausts retries; must not raise
|
|
62
64
|
# @param callback_wrapper [#call, nil] the session's
|
|
@@ -255,6 +257,20 @@ module ClaudeAgentSDK
|
|
|
255
257
|
by_path.each do |file_path, entries|
|
|
256
258
|
next if entries.empty? # avoid phantom keys in adapters that touch storage on append([])
|
|
257
259
|
|
|
260
|
+
if @projects_dir.nil?
|
|
261
|
+
# No CLAUDE_CONFIG_DIR and no usable home (SessionStores.projects_dir,
|
|
262
|
+
# #120): the frame cannot be mapped to a SessionKey. Unlike a path
|
|
263
|
+
# outside a KNOWN projects dir, this is a host misconfiguration that
|
|
264
|
+
# loses the whole mirror, so it is surfaced as a MirrorErrorMessage.
|
|
265
|
+
@dropped_batches += 1
|
|
266
|
+
message = "cannot mirror #{file_path}: the Claude config directory is unknown (CLAUDE_CONFIG_DIR is " \
|
|
267
|
+
'unset and the home directory could not be resolved); set CLAUDE_CONFIG_DIR in the ' \
|
|
268
|
+
'environment or options.env'
|
|
269
|
+
errors << [nil, message]
|
|
270
|
+
warn "Claude SDK: [SessionStore] #{message}"
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
|
|
258
274
|
key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
|
|
259
275
|
if key.nil?
|
|
260
276
|
@dropped_batches += 1
|
|
@@ -257,13 +257,13 @@ module ClaudeAgentSDK
|
|
|
257
257
|
end
|
|
258
258
|
end
|
|
259
259
|
|
|
260
|
-
def inspect_container(value, open, close, depth, seen, &
|
|
260
|
+
def inspect_container(value, open, close, depth, seen, &)
|
|
261
261
|
return "#{open}#{close}" if value.empty?
|
|
262
262
|
return "#{open}…(#{value.size})#{close}" if depth > INSPECT_MAX_DEPTH || seen.key?(value)
|
|
263
263
|
|
|
264
264
|
seen[value] = true
|
|
265
265
|
begin
|
|
266
|
-
parts = value.first(INSPECT_MAX_ITEMS).map(&
|
|
266
|
+
parts = value.first(INSPECT_MAX_ITEMS).map(&)
|
|
267
267
|
parts << "…(+#{value.size - INSPECT_MAX_ITEMS} more)" if value.size > INSPECT_MAX_ITEMS
|
|
268
268
|
"#{open}#{parts.join(', ')}#{close}"
|
|
269
269
|
ensure
|