claude-agent-sdk 0.35.0 → 0.37.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 +74 -0
- data/README.md +17 -8
- data/docs/cli-installer.md +16 -2
- data/docs/client.md +44 -4
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +27 -3
- data/docs/mcp-servers.md +36 -7
- data/docs/rails.md +3 -4
- data/docs/sessions.md +149 -34
- data/docs/types.md +106 -4
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +68 -11
- data/lib/claude_agent_sdk/command_builder.rb +109 -98
- data/lib/claude_agent_sdk/deprecation.rb +90 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
- data/lib/claude_agent_sdk/message_parser.rb +23 -9
- data/lib/claude_agent_sdk/observer.rb +2 -1
- data/lib/claude_agent_sdk/option_warnings.rb +2 -2
- data/lib/claude_agent_sdk/query.rb +99 -51
- data/lib/claude_agent_sdk/railtie.rb +14 -3
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
- data/lib/claude_agent_sdk/session_mutations.rb +28 -16
- data/lib/claude_agent_sdk/session_resume.rb +39 -35
- data/lib/claude_agent_sdk/session_store.rb +35 -21
- data/lib/claude_agent_sdk/session_summary.rb +12 -5
- data/lib/claude_agent_sdk/sessions.rb +112 -24
- data/lib/claude_agent_sdk/streaming.rb +1 -1
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
- data/lib/claude_agent_sdk/types/attributes.rb +271 -0
- data/lib/claude_agent_sdk/types/base.rb +320 -0
- data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
- data/lib/claude_agent_sdk/types/hooks.rb +640 -0
- data/lib/claude_agent_sdk/types/mcp.rb +232 -0
- data/lib/claude_agent_sdk/types/messages.rb +614 -0
- data/lib/claude_agent_sdk/types/option_values.rb +302 -0
- data/lib/claude_agent_sdk/types/options.rb +352 -0
- data/lib/claude_agent_sdk/types/permissions.rb +107 -0
- data/lib/claude_agent_sdk/types/sessions.rb +10 -0
- data/lib/claude_agent_sdk/types.rb +13 -2534
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +308 -73
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
- metadata +12 -1
|
@@ -56,8 +56,8 @@ module ClaudeAgentSDK
|
|
|
56
56
|
# mode) and a cancelled append may remain permanently half-applied in the
|
|
57
57
|
# store. The drop is surfaced (MirrorErrorMessage, batches_dropped?) and
|
|
58
58
|
# the local transcript remains the source of truth; the
|
|
59
|
-
# dedupe-by-entry-uuid recommendation above stays advisory. The method is
|
|
60
|
-
# `respond_to?(:callback_scheduling)` (see
|
|
59
|
+
# dedupe-by-entry-uuid recommendation above stays advisory. The method is
|
|
60
|
+
# deliberately NOT defined here: the SDK probes `respond_to?(:callback_scheduling)` (see
|
|
61
61
|
# SessionStores.store_callback_scheduling), so pure duck-typed adapters
|
|
62
62
|
# stay minimal, and an app can opt a third-party fiber-native adapter in
|
|
63
63
|
# via a singleton method (`def store.callback_scheduling = :inline`).
|
|
@@ -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
|
|
@@ -290,6 +290,8 @@ module ClaudeAgentSDK
|
|
|
290
290
|
end
|
|
291
291
|
|
|
292
292
|
# Internal SessionStore support functions (path mapping, option validation).
|
|
293
|
+
#
|
|
294
|
+
# @api private
|
|
293
295
|
module SessionStores
|
|
294
296
|
STORE_CALLBACK_SCHEDULING_MODES = %i[thread inline].freeze
|
|
295
297
|
|
|
@@ -349,7 +351,9 @@ module ClaudeAgentSDK
|
|
|
349
351
|
second = parts[1]
|
|
350
352
|
|
|
351
353
|
# Main transcript: <project_key>/<session_id>.jsonl
|
|
352
|
-
|
|
354
|
+
if parts.length == 2 && second.end_with?('.jsonl')
|
|
355
|
+
return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') }
|
|
356
|
+
end
|
|
353
357
|
|
|
354
358
|
# Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
|
|
355
359
|
if parts.length >= 4
|
|
@@ -396,27 +400,37 @@ module ClaudeAgentSDK
|
|
|
396
400
|
'(checkpoints are local-disk only and would diverge from the mirrored transcript)'
|
|
397
401
|
end
|
|
398
402
|
|
|
399
|
-
# Path to the rel-from base where
|
|
400
|
-
# CLAUDE_CONFIG_DIR
|
|
401
|
-
# Mirrors Sessions#config_dir but
|
|
403
|
+
# Path to the rel-from base where the CLI subprocess writes session
|
|
404
|
+
# transcripts: its CLAUDE_CONFIG_DIR, else ~/.claude under the home the
|
|
405
|
+
# CHILD sees. Mirrors Sessions#config_dir but resolves both through the
|
|
406
|
+
# options.env passed to the subprocess.
|
|
402
407
|
#
|
|
403
408
|
# Presence is detected by KEY, not value: the transport treats an explicit
|
|
404
409
|
# nil value as "unset the var for the child", so the CLI then writes under
|
|
405
410
|
# the default ~/.claude — not under the parent's CLAUDE_CONFIG_DIR. Empty
|
|
406
|
-
# strings get the same treatment (the Node CLI treats "" as unset).
|
|
411
|
+
# strings get the same treatment (the Node CLI treats "" as unset). A HOME
|
|
412
|
+
# in options.env likewise moves that default (see Sessions.home_dir).
|
|
413
|
+
#
|
|
414
|
+
# Returns nil when the default is needed but there is no usable home
|
|
415
|
+
# (#120). This runs at connect for every session with a session_store, so
|
|
416
|
+
# raising (as Sessions.config_dir does) would abort a fresh session over
|
|
417
|
+
# its secondary copy; the TranscriptMirrorBatcher instead reports the
|
|
418
|
+
# frames it cannot key as MirrorErrorMessage.
|
|
407
419
|
def projects_dir(env_override = nil)
|
|
408
|
-
if env_override.respond_to?(:key?) &&
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
+
override = if env_override.respond_to?(:key?) &&
|
|
421
|
+
(env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
|
|
422
|
+
env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
|
|
423
|
+
else
|
|
424
|
+
ENV.fetch('CLAUDE_CONFIG_DIR', nil)
|
|
425
|
+
end
|
|
426
|
+
override = nil if override.respond_to?(:empty?) && override.empty?
|
|
427
|
+
# NFC like Python's _get_projects_dir(env_override) — a decomposed
|
|
428
|
+
# Unicode override would otherwise mismatch the NFC paths used for
|
|
429
|
+
# the mirror's projects-dir prefix comparison and drop every frame.
|
|
430
|
+
return File.join(override.unicode_normalize(:nfc), 'projects') if override
|
|
431
|
+
|
|
432
|
+
home = Sessions.home_dir(env_override)
|
|
433
|
+
home && File.join(home, '.claude', 'projects').unicode_normalize(:nfc)
|
|
420
434
|
end
|
|
421
435
|
end
|
|
422
436
|
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.
|
|
@@ -16,6 +16,8 @@ module ClaudeAgentSDK
|
|
|
16
16
|
# (string keys from JSON), and the summary's opaque +data+ dict is persisted
|
|
17
17
|
# verbatim by adapters — string keys survive a JSON round-trip (Postgres
|
|
18
18
|
# JSONB, Redis) losslessly, whereas symbol keys would not.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
19
21
|
module SessionSummary
|
|
20
22
|
# JSONL entry keys -> summary data keys for last-wins string fields. Each
|
|
21
23
|
# appended entry overwrites the previous value when present.
|
|
@@ -49,7 +51,7 @@ module ClaudeAgentSDK
|
|
|
49
51
|
# @param key [Hash] the SessionKey (string keys)
|
|
50
52
|
# @param entries [Array<Hash>] newly appended transcript entries
|
|
51
53
|
# @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
|
|
52
|
-
def fold_session_summary(prev, key, entries)
|
|
54
|
+
def fold_session_summary(prev, key, entries) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- incremental fold over every summary field
|
|
53
55
|
summary = if prev
|
|
54
56
|
{ 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
|
|
55
57
|
else
|
|
@@ -69,8 +71,10 @@ module ClaudeAgentSDK
|
|
|
69
71
|
data['created_at'] = ms if ms
|
|
70
72
|
|
|
71
73
|
unless data.key?('cwd')
|
|
74
|
+
# First non-blank cwd (Sessions.presence: whitespace-only and
|
|
75
|
+
# invalidly encoded count as blank, as on the disk path).
|
|
72
76
|
cwd = entry['cwd']
|
|
73
|
-
data['cwd'] = cwd if cwd.is_a?(String) &&
|
|
77
|
+
data['cwd'] = cwd if cwd.is_a?(String) && presence(cwd)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
fold_first_prompt(data, entry)
|
|
@@ -113,7 +117,10 @@ module ClaudeAgentSDK
|
|
|
113
117
|
SDKSessionInfo.new(
|
|
114
118
|
session_id: entry['session_id'],
|
|
115
119
|
summary: summary,
|
|
116
|
-
|
|
120
|
+
# Integer epoch ms as documented, whatever shape the adapter stamped
|
|
121
|
+
# (ISO String, numeric String, Float, Time) — the value the listing
|
|
122
|
+
# orders by. Python passes entry["mtime"] through raw.
|
|
123
|
+
last_modified: Sessions.epoch_ms_mtime(entry['mtime']),
|
|
117
124
|
# file_size is a JSONL byte count — meaningful only for the local-disk
|
|
118
125
|
# path. Stores have no equivalent.
|
|
119
126
|
file_size: nil,
|
|
@@ -142,7 +149,7 @@ module ClaudeAgentSDK
|
|
|
142
149
|
# deliberately match the disk extractor (not Python's per-char replace) so
|
|
143
150
|
# the Ruby store path and disk path produce identical first_prompt values
|
|
144
151
|
# for the same transcript.
|
|
145
|
-
def fold_first_prompt(data, entry)
|
|
152
|
+
def fold_first_prompt(data, entry) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- must match the disk first-prompt extractor rule for rule
|
|
146
153
|
return if data['first_prompt_locked']
|
|
147
154
|
return unless entry['type'] == 'user'
|
|
148
155
|
return if entry['isMeta'] == true || entry['isCompactSummary'] == true
|
|
@@ -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'
|
|
@@ -82,7 +83,9 @@ module ClaudeAgentSDK
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
# Session browsing functions
|
|
85
|
-
|
|
86
|
+
#
|
|
87
|
+
# @api private
|
|
88
|
+
module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/reading functions share private helpers
|
|
86
89
|
LITE_READ_BUF_SIZE = 65_536
|
|
87
90
|
MAX_SANITIZED_LENGTH = 200
|
|
88
91
|
|
|
@@ -176,11 +179,53 @@ module ClaudeAgentSDK
|
|
|
176
179
|
# Get the Claude config directory (respects CLAUDE_CONFIG_DIR; an empty
|
|
177
180
|
# value is treated as unset, matching the Node CLI and the Python SDK).
|
|
178
181
|
# NFC-normalized on both branches like Python's _get_claude_config_home_dir.
|
|
182
|
+
#
|
|
183
|
+
# @raise [ConfigDirError] when CLAUDE_CONFIG_DIR is unset and there is no
|
|
184
|
+
# usable home directory (see .home_dir) for the default ~/.claude.
|
|
185
|
+
# Python raises too (Path.home() -> RuntimeError); `~` expansion here
|
|
186
|
+
# raised a bare ArgumentError from deep inside every disk session API.
|
|
179
187
|
def config_dir
|
|
180
188
|
dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil)
|
|
181
189
|
return dir.unicode_normalize(:nfc) if dir && !dir.empty?
|
|
182
190
|
|
|
183
|
-
|
|
191
|
+
home = home_dir
|
|
192
|
+
unless home
|
|
193
|
+
raise ConfigDirError,
|
|
194
|
+
'Cannot locate the Claude config directory: CLAUDE_CONFIG_DIR is unset and the home directory ' \
|
|
195
|
+
'could not be resolved (HOME is unset, empty or relative, and the user has no passwd entry). ' \
|
|
196
|
+
'Set CLAUDE_CONFIG_DIR to the directory holding your Claude Code data (normally ~/.claude).'
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
File.join(home, '.claude').unicode_normalize(:nfc)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A usable home directory, or nil when there is none. The ONE definition
|
|
203
|
+
# of "home" for the SDK (CLI discovery, disk session APIs, the transcript
|
|
204
|
+
# mirror, store-backed resume seeding).
|
|
205
|
+
#
|
|
206
|
+
# Without +env+, the parent process's home: Dir.home raises ArgumentError
|
|
207
|
+
# when HOME is unset and the uid has no passwd entry (docker --user in a
|
|
208
|
+
# minimal image), and returns an empty or relative HOME verbatim — a path
|
|
209
|
+
# under "" or a cwd-relative dir is not where the user's data lives (and
|
|
210
|
+
# a relative CLI hit would be spawned from options.cwd, i.e. a different
|
|
211
|
+
# file), so both read as "no home".
|
|
212
|
+
#
|
|
213
|
+
# With +env+ (a ClaudeAgentOptions#env Hash), the home the CLI CHILD will
|
|
214
|
+
# see: a HOME key there wins, by key presence like the CLAUDE_CONFIG_DIR
|
|
215
|
+
# override, and must be absolute. An explicit nil (the transport unsets
|
|
216
|
+
# HOME for the child, which then falls back to its passwd entry) is also
|
|
217
|
+
# treated as no home rather than guessing that entry.
|
|
218
|
+
#
|
|
219
|
+
# @api private
|
|
220
|
+
def home_dir(env = nil)
|
|
221
|
+
home = if env.respond_to?(:key?) && (env.key?('HOME') || env.key?(:HOME))
|
|
222
|
+
env['HOME'] || env[:HOME]
|
|
223
|
+
else
|
|
224
|
+
Dir.home
|
|
225
|
+
end
|
|
226
|
+
home if home.is_a?(String) && File.absolute_path?(home)
|
|
227
|
+
rescue ArgumentError
|
|
228
|
+
nil
|
|
184
229
|
end
|
|
185
230
|
|
|
186
231
|
# Find the project directory for a given path
|
|
@@ -239,7 +284,11 @@ module ClaudeAgentSDK
|
|
|
239
284
|
# top-level shape can't be checked, and dropping it would regress the
|
|
240
285
|
# common case of a true entry cut by the 64KB window. Unverified blanks
|
|
241
286
|
# cannot clear a previously verified value: they may be nested tool inputs.
|
|
242
|
-
|
|
287
|
+
#
|
|
288
|
+
# +skip_blank+ passes over verified blank values too, for set-once fields
|
|
289
|
+
# the store fold only takes when non-blank (cwd): there a blank entry is
|
|
290
|
+
# absent, not a clearing entry.
|
|
291
|
+
def extract_top_level_string_field(text, key, last: false, skip_blank: false)
|
|
243
292
|
positions = field_match_positions(text, key)
|
|
244
293
|
positions.reverse! if last
|
|
245
294
|
parsed_lines = {}
|
|
@@ -250,9 +299,9 @@ module ClaudeAgentSDK
|
|
|
250
299
|
end
|
|
251
300
|
if entry
|
|
252
301
|
value = entry[key]
|
|
253
|
-
return value if value.is_a?(String)
|
|
302
|
+
return value if value.is_a?(String) && (!skip_blank || presence(value))
|
|
254
303
|
|
|
255
|
-
next # parseable line without a top-level string value: nested/false match
|
|
304
|
+
next # parseable line without a usable top-level string value: nested/false match
|
|
256
305
|
end
|
|
257
306
|
value = extract_json_string_value(text, value_start)
|
|
258
307
|
value = presence(unescape_json_string(value)) if value
|
|
@@ -334,6 +383,8 @@ module ClaudeAgentSDK
|
|
|
334
383
|
# a NoMethodError from deep inside (`123.match?`, `123.empty?`) — and so
|
|
335
384
|
# does an invalidly encoded String, on which the regexp match itself
|
|
336
385
|
# raises ArgumentError ("invalid byte sequence").
|
|
386
|
+
#
|
|
387
|
+
# @api private
|
|
337
388
|
def valid_session_id?(session_id)
|
|
338
389
|
session_id.is_a?(String) && session_id.valid_encoding? && session_id.match?(UUID_RE)
|
|
339
390
|
end
|
|
@@ -352,12 +403,26 @@ module ClaudeAgentSDK
|
|
|
352
403
|
# bare ArgumentError. Coerce defensively wherever an adapter mtime is
|
|
353
404
|
# ordered or compared: numeric strings and ISO-8601 both order
|
|
354
405
|
# correctly; anything else sorts last rather than crashing.
|
|
406
|
+
#
|
|
407
|
+
# A Time (e.g. an ActiveRecord updated_at) counts as its instant in epoch
|
|
408
|
+
# ms. Non-finite values (Infinity, "1e400") also read as 0: they cannot be
|
|
409
|
+
# ordered against real clocks nor reported as epoch ms.
|
|
355
410
|
def sortable_mtime(value)
|
|
356
|
-
case value
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
411
|
+
ms = case value
|
|
412
|
+
when Numeric then value
|
|
413
|
+
when Time then value.to_r * 1000
|
|
414
|
+
when String then Float(value, exception: false) || parse_iso_timestamp_ms(value)
|
|
415
|
+
end
|
|
416
|
+
ms.is_a?(Numeric) && ms.real? && ms.finite? ? ms : 0
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# An adapter mtime as SDKSessionInfo#last_modified promises it: Integer
|
|
420
|
+
# epoch milliseconds. Same coercion as sortable_mtime (so a row reports
|
|
421
|
+
# the value it is ordered by), truncated to whole milliseconds.
|
|
422
|
+
#
|
|
423
|
+
# @api private
|
|
424
|
+
def epoch_ms_mtime(value)
|
|
425
|
+
sortable_mtime(value).to_i
|
|
361
426
|
end
|
|
362
427
|
|
|
363
428
|
# Sort key shared by every session listing, disk and store: newest first,
|
|
@@ -365,12 +430,14 @@ module ClaudeAgentSDK
|
|
|
365
430
|
# the secondary key equal mtimes (coarse adapter clocks, bulk imports)
|
|
366
431
|
# ordered arbitrarily between calls and offset/limit paging could skip or
|
|
367
432
|
# repeat sessions; one key also keeps the two paths in the same order.
|
|
433
|
+
#
|
|
434
|
+
# @api private
|
|
368
435
|
def listing_sort_key(mtime, session_id)
|
|
369
436
|
[-sortable_mtime(mtime), session_id.to_s]
|
|
370
437
|
end
|
|
371
438
|
|
|
372
439
|
# Extract the first meaningful user prompt from the head of a JSONL file
|
|
373
|
-
def extract_first_prompt_from_head(head)
|
|
440
|
+
def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold
|
|
374
441
|
command_fallback = nil
|
|
375
442
|
|
|
376
443
|
head.each_line do |line|
|
|
@@ -482,7 +549,7 @@ module ClaudeAgentSDK
|
|
|
482
549
|
[head, tail]
|
|
483
550
|
end
|
|
484
551
|
|
|
485
|
-
def build_session_info(file_path, head, tail, stat, project_path)
|
|
552
|
+
def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute
|
|
486
553
|
# User-set title (customTitle) wins over AI-generated title (aiTitle).
|
|
487
554
|
# Consult the head only when the tail has no occurrence of that field.
|
|
488
555
|
# Normalize blanks AFTER choosing the latest occurrence: an explicit
|
|
@@ -495,7 +562,9 @@ module ClaudeAgentSDK
|
|
|
495
562
|
extract_top_level_string_field(head, 'customTitle', last: true)) ||
|
|
496
563
|
presence(extract_top_level_string_field(tail, 'aiTitle', last: true) ||
|
|
497
564
|
extract_top_level_string_field(head, 'aiTitle', last: true))
|
|
498
|
-
|
|
565
|
+
# nil, not '', when there is no prompt — the store path's answer, and
|
|
566
|
+
# Python's (`_extract_first_prompt_from_head(head) or None`).
|
|
567
|
+
first_prompt = presence(extract_first_prompt_from_head(head))
|
|
499
568
|
# lastPrompt tail entry shows what the user was most recently doing.
|
|
500
569
|
summary = custom_title ||
|
|
501
570
|
presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
|
|
@@ -528,7 +597,11 @@ module ClaudeAgentSDK
|
|
|
528
597
|
# (SessionSummary.summary_entry_to_sdk_info) reports it.
|
|
529
598
|
git_branch: presence(extract_json_string_field(tail, 'gitBranch', last: true) ||
|
|
530
599
|
extract_json_string_field(head, 'gitBranch', last: false)),
|
|
531
|
-
|
|
600
|
+
# The first non-blank TOP-LEVEL cwd, exactly what the store fold keeps
|
|
601
|
+
# (set-once, blank skipped): taking the first match even when blank
|
|
602
|
+
# fell back to the project path where the store read a later entry's
|
|
603
|
+
# cwd, and the raw scan also matched cwd keys nested in tool inputs.
|
|
604
|
+
cwd: extract_top_level_string_field(head, 'cwd', skip_blank: true) || project_path,
|
|
532
605
|
tag: tag_value,
|
|
533
606
|
created_at: created_at
|
|
534
607
|
)
|
|
@@ -927,7 +1000,7 @@ module ClaudeAgentSDK
|
|
|
927
1000
|
# NotImplementedError (caller falls back to the slow path). Sessions missing
|
|
928
1001
|
# a sidecar or whose sidecar is stale (summary.mtime < the session's current
|
|
929
1002
|
# mtime) are routed through gap-fill so the fold is recomputed from source.
|
|
930
|
-
def list_sessions_via_summaries(store, project_key, project_path, limit, offset)
|
|
1003
|
+
def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill
|
|
931
1004
|
begin
|
|
932
1005
|
# Array(): a non-conformant store returning nil (e.g. a NULL JSONB read)
|
|
933
1006
|
# degrades to gap-fill instead of crashing on nil.each, matching the
|
|
@@ -975,7 +1048,7 @@ module ClaudeAgentSDK
|
|
|
975
1048
|
# leaves a short page; loads stay bounded to ~offset + limit + (the dropped
|
|
976
1049
|
# placeholders encountered before the page fills), preserving the fast
|
|
977
1050
|
# path's "don't load every session" intent.
|
|
978
|
-
def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset)
|
|
1051
|
+
def paginate_resolving_gaps(store, project_key, project_path, slots, limit, offset) # rubocop:disable Metrics/ParameterLists -- pagination state threaded explicitly
|
|
979
1052
|
offset = 0 unless offset&.positive?
|
|
980
1053
|
results = []
|
|
981
1054
|
skipped = 0
|
|
@@ -1011,7 +1084,7 @@ module ClaudeAgentSDK
|
|
|
1011
1084
|
# its mtime) rather than aborting the whole listing — matches the disk
|
|
1012
1085
|
# path's per-file rescue and the store path's degrade-the-row contract.
|
|
1013
1086
|
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])
|
|
1087
|
+
return SDKSessionInfo.new(session_id: sid, summary: '', last_modified: epoch_ms_mtime(slot[:mtime]))
|
|
1015
1088
|
end
|
|
1016
1089
|
return nil if entries.nil? || entries.empty?
|
|
1017
1090
|
|
|
@@ -1288,7 +1361,9 @@ module ClaudeAgentSDK
|
|
|
1288
1361
|
return [] unless File.directory?(projects_dir)
|
|
1289
1362
|
|
|
1290
1363
|
all_sessions = []
|
|
1291
|
-
|
|
1364
|
+
# Sorted: the scan order is deduplicate_sessions' last tiebreak, and
|
|
1365
|
+
# Dir.children returns filesystem order.
|
|
1366
|
+
Dir.children(projects_dir).sort.each do |child|
|
|
1292
1367
|
dir = File.join(projects_dir, child)
|
|
1293
1368
|
next unless File.directory?(dir)
|
|
1294
1369
|
|
|
@@ -1298,15 +1373,26 @@ module ClaudeAgentSDK
|
|
|
1298
1373
|
deduplicate_sessions(all_sessions)
|
|
1299
1374
|
end
|
|
1300
1375
|
|
|
1376
|
+
# One entry per session_id when the same session sits in several project
|
|
1377
|
+
# dirs (copied config dirs, worktrees). The newest last_modified wins; on
|
|
1378
|
+
# equal mtimes the larger file (the more complete copy), and then the
|
|
1379
|
+
# copy scanned first — project dirs in name order for the global listing,
|
|
1380
|
+
# worktrees in `git worktree list` order (main worktree first) for a
|
|
1381
|
+
# directory listing. Python keeps the first copy seen in iterdir() order
|
|
1382
|
+
# (sessions.py _deduplicate_by_session_id), which is arbitrary on a tie.
|
|
1301
1383
|
def deduplicate_sessions(sessions)
|
|
1302
1384
|
by_id = {}
|
|
1303
1385
|
sessions.each do |s|
|
|
1304
1386
|
existing = by_id[s.session_id]
|
|
1305
|
-
by_id[s.session_id] = s if existing.nil? || s
|
|
1387
|
+
by_id[s.session_id] = s if existing.nil? || (dedup_rank(s) <=> dedup_rank(existing)).positive?
|
|
1306
1388
|
end
|
|
1307
1389
|
by_id.values
|
|
1308
1390
|
end
|
|
1309
1391
|
|
|
1392
|
+
def dedup_rank(session)
|
|
1393
|
+
[session.last_modified, session.file_size.to_i]
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1310
1396
|
# Probe git for the worktree list with a hard 5-second cap. A stale
|
|
1311
1397
|
# git lock or hung network mount must not block the listing path
|
|
1312
1398
|
# forever. Stdlib `Timeout.timeout` raises across threads via
|
|
@@ -1315,7 +1401,7 @@ module ClaudeAgentSDK
|
|
|
1315
1401
|
# threads (so a full pipe buffer can't deadlock git) and SIGKILL the
|
|
1316
1402
|
# child if the deadline passes. Matches Python's
|
|
1317
1403
|
# `subprocess.run(..., timeout=5)`.
|
|
1318
|
-
def detect_worktrees(path)
|
|
1404
|
+
def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill
|
|
1319
1405
|
stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
|
|
1320
1406
|
stdin.close
|
|
1321
1407
|
|
|
@@ -1544,12 +1630,12 @@ module ClaudeAgentSDK
|
|
|
1544
1630
|
|
|
1545
1631
|
private_class_method :get_session_info_for_directory,
|
|
1546
1632
|
:list_sessions_for_directory, :list_all_sessions,
|
|
1547
|
-
:deduplicate_sessions,
|
|
1633
|
+
:deduplicate_sessions, :dedup_rank,
|
|
1548
1634
|
:find_session_file, :stat_candidate, :resolve_subagents_dir,
|
|
1549
1635
|
:collect_agent_files, :parse_jsonl_entries,
|
|
1550
1636
|
:build_conversation_chain, :walk_to_leaf, :walk_to_root,
|
|
1551
1637
|
:filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts,
|
|
1552
|
-
:
|
|
1638
|
+
:valid_agent_id?, :sidechain_head?,
|
|
1553
1639
|
:list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot,
|
|
1554
1640
|
:derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset,
|
|
1555
1641
|
:filter_transcript_entries, :entries_to_messages,
|
|
@@ -1557,7 +1643,9 @@ module ClaudeAgentSDK
|
|
|
1557
1643
|
:import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files,
|
|
1558
1644
|
:read_agent_metadata_sidecar, :parent_ids_from_agent_metadata
|
|
1559
1645
|
|
|
1560
|
-
# These remain accessible for SessionMutations:
|
|
1561
|
-
# config_dir, sanitize_path, find_project_dir, detect_worktrees
|
|
1646
|
+
# These remain accessible for SessionMutations / SessionResume:
|
|
1647
|
+
# config_dir, sanitize_path, find_project_dir, detect_worktrees,
|
|
1648
|
+
# valid_session_id? (mutation boundary checks), listing_sort_key
|
|
1649
|
+
# (--continue candidate order)
|
|
1562
1650
|
end
|
|
1563
1651
|
end
|