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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. 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 deliberately NOT defined here: the SDK probes
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, list_sessions_from_store raises.
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, list_sessions_from_store falls back to
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
- return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')
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 session transcripts live, honoring a
400
- # CLAUDE_CONFIG_DIR override passed to the subprocess via options.env.
401
- # Mirrors Sessions#config_dir but consults an explicit env override first.
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
- (env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
410
- override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
411
- override = nil if override.respond_to?(:empty?) && override.empty?
412
- # NFC like Python's _get_projects_dir(env_override) — a decomposed
413
- # Unicode override would otherwise mismatch the NFC paths used for
414
- # the mirror's projects-dir prefix comparison and drop every frame.
415
- override = override.unicode_normalize(:nfc) if override
416
- return File.join(override || File.expand_path('~/.claude'), 'projects')
417
- end
418
-
419
- File.join(Sessions.config_dir, 'projects')
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 list_sessions_from_store can fetch all
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) && !cwd.empty?
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
- last_modified: entry['mtime'],
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
- module Sessions # rubocop:disable Metrics/ModuleLength
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
- File.expand_path('~/.claude').unicode_normalize(:nfc)
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
- def extract_top_level_string_field(text, key, last: false)
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
- when Numeric then value
358
- when String then Float(value, exception: false) || parse_iso_timestamp_ms(value) || 0
359
- else 0
360
- end
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
- first_prompt = extract_first_prompt_from_head(head)
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
- cwd: presence(extract_json_string_field(head, 'cwd', last: false)) || project_path,
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
- Dir.children(projects_dir).each do |child|
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.last_modified > existing.last_modified
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
- :valid_session_id?, :valid_agent_id?, :listing_sort_key, :sidechain_head?,
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
@@ -21,7 +21,7 @@ module ClaudeAgentSDK
21
21
  parent_tool_use_id: parent_tool_use_id,
22
22
  session_id: session_id
23
23
  }
24
- JSON.generate(message) + "\n"
24
+ "#{JSON.generate(message)}\n"
25
25
  end
26
26
 
27
27
  # Create an Enumerator from an array of messages