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.
@@ -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 override used to
100
- # locate the projects dir (already repointed at the temp dir when resuming
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
- sessions.sort_by { |s| -Sessions.sortable_mtime(s['mtime']) }.each do |cand|
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, &block)
297
- FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &block)
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
- # 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.
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, :home_dir, :encode_candidate, :encode_jsonl_lines, :encode_entry,
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, 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
@@ -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 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.
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
- (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')
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 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.
@@ -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) && !cwd.empty?
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
- last_modified: entry['mtime'],
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
- File.expand_path('~/.claude').unicode_normalize(:nfc)
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
- def extract_top_level_string_field(text, key, last: false)
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
- when Numeric then value
358
- when String then Float(value, exception: false) || parse_iso_timestamp_ms(value) || 0
359
- else 0
360
- end
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
- first_prompt = extract_first_prompt_from_head(head)
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
- cwd: presence(extract_json_string_field(head, 'cwd', last: false)) || project_path,
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
- Dir.children(projects_dir).each do |child|
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.last_modified > existing.last_modified
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
- :valid_session_id?, :valid_agent_id?, :listing_sort_key, :sidechain_head?,
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(&block)
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 when none
1054
- # is usable. Dir.home raises ArgumentError when HOME is unset and the uid
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
- home = Dir.home
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
- # Under Rails, anchor to the app root instead of the process cwd (the
13
- # app is not booted: no :environment dependency, so this runs in a
14
- # Docker build without credentials). Resolved when the task runs.
15
- root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
16
- dir = root&.join('vendor', 'claude')&.to_s
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
- # list_sessions_from_store.
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 mapping
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, &render)
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(&render)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.35.0'
4
+ VERSION = '0.36.0'
5
5
  end