claude-agent-sdk 1.1.0 → 1.2.1

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/.yardopts +10 -0
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +83 -108
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/option_forms.rb +379 -0
  22. data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
  23. data/lib/claude_agent_sdk/query.rb +266 -364
  24. data/lib/claude_agent_sdk/railtie.rb +27 -2
  25. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  26. data/lib/claude_agent_sdk/session_assembly.rb +311 -0
  27. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  28. data/lib/claude_agent_sdk/session_resume.rb +360 -43
  29. data/lib/claude_agent_sdk/session_store.rb +31 -2
  30. data/lib/claude_agent_sdk/sessions.rb +720 -138
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
  32. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  33. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  34. data/lib/claude_agent_sdk/transport.rb +28 -12
  35. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  36. data/lib/claude_agent_sdk/types/base.rb +85 -15
  37. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  38. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  39. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  40. data/lib/claude_agent_sdk/types/options.rb +35 -5
  41. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +223 -418
  44. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  45. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  46. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  47. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  48. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  49. metadata +9 -4
@@ -27,19 +27,25 @@ module ClaudeAgentSDK
27
27
 
28
28
  # Rename a session by appending a custom-title entry.
29
29
  #
30
- # list_sessions reads the LAST custom-title from the file tail, so
31
- # repeated calls are safe — the most recent wins.
30
+ # Repeated calls are safe: the disk listing takes the LAST custom-title
31
+ # in the final 64 KiB of the file (then in the first 64 KiB), the store
32
+ # fold the last one overall. On disk the entry is only seen while it
33
+ # stays inside one of those windows: rename a session that is still
34
+ # running, let 64 KiB of transcript follow, and list_sessions /
35
+ # get_session_info report the previous title again until the CLI resumes
36
+ # the session and re-appends its metadata at the end (the same holds for
37
+ # tag_session). Not fixed here: finding it again means reading the whole
38
+ # file on every listing.
32
39
  #
33
40
  # @param session_id [String] UUID of the session to rename
34
41
  # @param title [String] New session title (whitespace stripped)
35
42
  # @param directory [String, nil] Project directory path
36
- # @raise [ArgumentError] if session_id is invalid or title is empty
43
+ # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String
37
44
  # @raise [Errno::ENOENT] if the session file cannot be found
38
45
  def rename_session(session_id:, title:, directory: nil)
39
46
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
40
47
 
41
- stripped = title.strip
42
- raise ArgumentError, 'title must be non-empty' if stripped.empty?
48
+ stripped = stripped_title(title)
43
49
 
44
50
  data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n"
45
51
 
@@ -54,19 +60,12 @@ module ClaudeAgentSDK
54
60
  # @param session_id [String] UUID of the session to tag
55
61
  # @param tag [String, nil] Tag string, or nil to clear
56
62
  # @param directory [String, nil] Project directory path
57
- # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
63
+ # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization
58
64
  # @raise [Errno::ENOENT] if the session file cannot be found
59
65
  def tag_session(session_id:, tag:, directory: nil)
60
66
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
61
67
 
62
- if tag
63
- sanitized = sanitize_unicode(tag).strip
64
- raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
65
-
66
- tag = sanitized
67
- end
68
-
69
- data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n"
68
+ data = "#{JSON.generate({ type: 'tag', tag: sanitized_tag(tag), sessionId: session_id })}\n"
70
69
 
71
70
  append_to_session(session_id, data, directory)
72
71
  end
@@ -164,13 +163,12 @@ module ClaudeAgentSDK
164
163
  # appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe
165
164
  # by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
166
165
  #
167
- # @raise [ArgumentError] if session_id is invalid or title is empty
166
+ # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String
168
167
  # @raise [Errno::ENOENT] if the session is not found in the store
169
168
  def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
170
169
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
171
170
 
172
- stripped = title.strip
173
- raise ArgumentError, 'title must be non-empty' if stripped.empty?
171
+ stripped = stripped_title(title)
174
172
 
175
173
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
176
174
  ensure_store_session_exists(session_store, key)
@@ -188,23 +186,17 @@ module ClaudeAgentSDK
188
186
  # counterpart to tag_session. Pass nil to clear the tag. Tags are
189
187
  # Unicode-sanitized before storing.
190
188
  #
191
- # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
189
+ # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization
192
190
  # @raise [Errno::ENOENT] if the session is not found in the store
193
191
  def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
194
192
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
195
193
 
196
- if tag
197
- sanitized = sanitize_unicode(tag).strip
198
- raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
199
-
200
- tag = sanitized
201
- end
202
-
194
+ tag = sanitized_tag(tag)
203
195
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
204
196
  ensure_store_session_exists(session_store, key)
205
197
  session_store.append(key, [{
206
198
  'type' => 'tag',
207
- 'tag' => tag || '',
199
+ 'tag' => tag,
208
200
  'sessionId' => session_id,
209
201
  'uuid' => SecureRandom.uuid,
210
202
  'timestamp' => iso_now
@@ -222,10 +214,9 @@ module ClaudeAgentSDK
222
214
  # @raise [ArgumentError] if session_id is invalid
223
215
  def delete_session_via_store(session_store:, session_id:, directory: nil)
224
216
  raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
225
- return unless SessionStore.implements?(session_store, :delete)
226
217
 
227
218
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
228
- session_store.delete(key)
219
+ SessionStores.optional_call(session_store, :delete) { session_store.delete(key) }
229
220
  nil
230
221
  end
231
222
 
@@ -263,6 +254,49 @@ module ClaudeAgentSDK
263
254
 
264
255
  # -- Private helpers --
265
256
 
257
+ # The title to store: stripped and non-empty. A value that is not a usable
258
+ # String (nil, another type, bytes invalid in their encoding) gets the
259
+ # ArgumentError an empty title gets — the boundary check the session ids
260
+ # have — where calling #strip on it raised NoMethodError or an encoding
261
+ # error from inside.
262
+ def stripped_title(title)
263
+ text = utf8_text(title)
264
+ stripped = text ? text.strip : ''
265
+ raise ArgumentError, 'title must be non-empty' if stripped.empty?
266
+
267
+ stripped
268
+ end
269
+
270
+ # +value+ as UTF-8 text, or nil when it is not usable text: not a String,
271
+ # or bytes that are not valid text. A binary String (ASCII-8BIT, what
272
+ # File.binread returns) always reports valid_encoding?, so it is read as
273
+ # the UTF-8 it usually holds and checked as such; a String in another
274
+ # encoding is transcoded. Without this, binary bytes that are not UTF-8
275
+ # got past the check and failed later as JSON::GeneratorError or
276
+ # Encoding::CompatibilityError instead of the documented ArgumentError.
277
+ def utf8_text(value)
278
+ return nil unless value.is_a?(String)
279
+
280
+ text = value.encoding == Encoding::BINARY ? value.dup.force_encoding(Encoding::UTF_8) : value.encode(Encoding::UTF_8)
281
+ text.valid_encoding? ? text : nil
282
+ rescue EncodingError
283
+ nil
284
+ end
285
+
286
+ # The tag to store: Unicode-sanitized and stripped, or '' (which clears
287
+ # the tag) for nil — only nil: false is not a tag either, and clearing on
288
+ # it would turn a Boolean from untyped input into a destructive write.
289
+ # Same boundary check as stripped_title.
290
+ def sanitized_tag(tag)
291
+ return '' if tag.nil?
292
+
293
+ text = utf8_text(tag)
294
+ sanitized = text ? sanitize_unicode(text).strip : ''
295
+ raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
296
+
297
+ sanitized
298
+ end
299
+
266
300
  # Raise Errno::ENOENT (as the disk counterparts and fork_session_via_store
267
301
  # do) unless the store holds entries for +key+. Without this probe, a
268
302
  # rename/tag of a typo'd or stale id APPENDED metadata to a never-written
@@ -293,8 +327,12 @@ module ClaudeAgentSDK
293
327
  end
294
328
 
295
329
  def find_in_directory(file_name, directory)
296
- path = File.realpath(directory).unicode_normalize(:nfc)
297
- result = try_project_dir(file_name, Sessions.find_project_dir(path))
330
+ # canonicalize_path, not File.realpath: the transcripts outlive the
331
+ # directory (a removed worktree), and realpath raised Errno::ENOENT for
332
+ # it before the session was even looked for — while the readers, which
333
+ # canonicalize, still found the session through the same directory.
334
+ path = Sessions.canonicalize_path(directory)
335
+ result = try_project_dir(file_name, Sessions.find_project_dir(path), path)
298
336
  return result if result
299
337
 
300
338
  worktree_paths = begin
@@ -305,7 +343,7 @@ module ClaudeAgentSDK
305
343
  worktree_paths.each do |wt_path|
306
344
  next if wt_path == path
307
345
 
308
- result = try_project_dir(file_name, Sessions.find_project_dir(wt_path))
346
+ result = try_project_dir(file_name, Sessions.find_project_dir(wt_path), wt_path)
309
347
  return result if result
310
348
  end
311
349
  nil
@@ -315,11 +353,17 @@ module ClaudeAgentSDK
315
353
  # in one project dir must not stop the search when the real transcript
316
354
  # lives under another (worktree) project dir. Mirrors the read path
317
355
  # (Sessions.stat_candidate) and the append path (try_append).
318
- def try_project_dir(file_name, project_dir)
356
+ # With +path+ (a directory-scoped lookup), the candidate must also be one
357
+ # of that path's own transcripts (Sessions.own_transcript?: a directory
358
+ # the long-path fallback found can hold other paths' sessions).
359
+ def try_project_dir(file_name, project_dir, path = nil)
319
360
  return nil unless project_dir
320
361
 
321
362
  candidate = File.join(project_dir, file_name)
322
- File.size(candidate).positive? ? [candidate, project_dir] : nil
363
+ return nil unless File.size(candidate).positive?
364
+ return nil if path && !Sessions.own_transcript?(project_dir, candidate, path)
365
+
366
+ [candidate, project_dir]
323
367
  rescue SystemCallError
324
368
  nil
325
369
  end
@@ -433,9 +477,10 @@ module ClaudeAgentSDK
433
477
  })
434
478
  end
435
479
 
436
- # Derive title: explicit > original customTitle > original aiTitle > first
437
- # prompt, suffixed with " (fork)" when derived. listSessions reads the LAST
438
- # custom-title from the tail, so this trailer is what surfaces.
480
+ # Derive title: explicit > the source's listed title (custom, else AI) >
481
+ # its first prompt, suffixed with " (fork)" when derived. listSessions
482
+ # reads the LAST custom-title from the tail, so this trailer is what
483
+ # surfaces.
439
484
  fork_title = title&.strip
440
485
  fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty?
441
486
 
@@ -472,61 +517,30 @@ module ClaudeAgentSDK
472
517
  [transcript, content_replacements]
473
518
  end
474
519
 
475
- # Derive a fork title by scanning already-parsed store entries — the store
476
- # path's analogue of derive_fork_title's head/tail byte scan. Last occurrence
477
- # wins for both customTitle and aiTitle; customTitle beats aiTitle; the first
478
- # user prompt is the final fallback. Returns nil when nothing is found (the
479
- # caller supplies the "Forked session" default). This scans the RAW entries,
480
- # not the partitioned transcript (which drops customTitle/aiTitle metadata) —
481
- # the store half of #837's P0-1 fix.
520
+ # Derive a fork title from already-parsed store entries: the title the
521
+ # store listing shows for the session (custom title, else AI title — the
522
+ # latest occurrence of each, a blank one counting as absent), else its
523
+ # first prompt. Folds the RAW entries (the partitioned transcript has
524
+ # dropped the customTitle/aiTitle metadata — the store half of #837's
525
+ # P0-1 fix) with the fold the listing uses, and takes the title from the
526
+ # folded fields rather than from summary_entry_to_sdk_info: that returns
527
+ # nil for a sidechain or summary-less session, which can still be forked.
528
+ # nil when the session has none of the three (the caller supplies the
529
+ # "Forked session" default).
482
530
  def derive_title_from_entries(raw)
483
- custom = nil
484
- ai = nil
485
- raw.each do |e|
486
- next unless e.is_a?(Hash)
487
-
488
- ct = e['customTitle']
489
- custom = ct if ct.is_a?(String) && !ct.empty?
490
- at = e['aiTitle']
491
- ai = at if at.is_a?(String) && !at.empty?
492
- end
493
- return custom if custom
494
- return ai if ai
495
-
496
- # First-prompt fallback: re-serialize to a JSONL string and reuse the head
497
- # extractor so skip-patterns/truncation match the disk path exactly.
498
- # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
499
- # prompt qualifies — normalize to nil so the caller's 'Forked session'
500
- # default actually fires (Python appends `or None` here for this reason).
501
- jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n"
502
- title = Sessions.extract_first_prompt_from_head(jsonl)
503
- title.nil? || title.empty? ? nil : title
531
+ data = SessionSummary.fold_session_summary(nil, {}, raw)['data']
532
+ first_prompt = data['first_prompt_locked'] ? data['first_prompt'] : data['command_fallback']
533
+ Sessions.display_title(data['custom_title'], data['ai_title']) || Sessions.presence(first_prompt)
504
534
  end
505
535
 
506
- # Derive a fork title from the source file's head/tail chunks without
507
- # slurping the entire file. Matches the lookup order used for
508
- # SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when
509
- # nothing is found (build_fork_lines supplies the "Forked session" default).
536
+ # Derive a fork title from the source file without slurping it: the title
537
+ # and first prompt the disk listing reports for the session, taken from
538
+ # the same head/tail windows by the same rule. nil when it has neither
539
+ # (build_fork_lines supplies the "Forked session" default).
510
540
  def derive_fork_title(file_path, file_size)
511
- buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min
512
- File.open(file_path, 'rb') do |f|
513
- head = (f.read(buf_size) || '').force_encoding('UTF-8').scrub
514
- tail = if file_size > Sessions::LITE_READ_BUF_SIZE
515
- f.seek(-buf_size, IO::SEEK_END)
516
- (f.read(buf_size) || '').force_encoding('UTF-8').scrub
517
- else
518
- head
519
- end
520
- title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
521
- Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
522
- Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
523
- Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
524
- Sessions.extract_first_prompt_from_head(head)
525
- # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
526
- # prompt qualifies — normalize to nil so the 'Forked session' default
527
- # fires (Python appends `or None` here for the same reason).
528
- title.nil? || title.empty? ? nil : title
529
- end
541
+ head, tail = Sessions.read_head_tail(file_path, file_size)
542
+ title, first_prompt = Sessions.title_and_first_prompt(file_path, head, tail, file_size)
543
+ title || first_prompt
530
544
  end
531
545
 
532
546
  # Build a single forked entry with remapped UUIDs.
@@ -587,11 +601,11 @@ module ClaudeAgentSDK
587
601
  end
588
602
 
589
603
  def append_to_session_in_directory(session_id, data, file_name, directory)
590
- path = File.realpath(directory).unicode_normalize(:nfc)
604
+ path = Sessions.canonicalize_path(directory) # see find_in_directory
591
605
 
592
606
  # Try the exact/prefix-matched project directory first.
593
607
  project_dir = Sessions.find_project_dir(path)
594
- return if project_dir && try_append(File.join(project_dir, file_name), data)
608
+ return if project_dir && own_append(project_dir, file_name, path, data)
595
609
 
596
610
  # Worktree fallback
597
611
  begin
@@ -604,7 +618,7 @@ module ClaudeAgentSDK
604
618
  next false if wt_path == path
605
619
 
606
620
  wt_project_dir = Sessions.find_project_dir(wt_path)
607
- wt_project_dir && try_append(File.join(wt_project_dir, file_name), data)
621
+ wt_project_dir && own_append(wt_project_dir, file_name, wt_path, data)
608
622
  end
609
623
  return if found
610
624
 
@@ -626,6 +640,12 @@ module ClaudeAgentSDK
626
640
  raise Errno::ENOENT, "Session #{session_id} not found in any project directory"
627
641
  end
628
642
 
643
+ # try_append, for a transcript of +path+'s own (Sessions.own_transcript?).
644
+ def own_append(project_dir, file_name, path, data)
645
+ candidate = File.join(project_dir, file_name)
646
+ Sessions.own_transcript?(project_dir, candidate, path) && try_append(candidate, data)
647
+ end
648
+
629
649
  # Try appending to a path.
630
650
  #
631
651
  # Opens with WRONLY | APPEND (no CREAT) so the open fails with
@@ -675,11 +695,11 @@ module ClaudeAgentSDK
675
695
  'Other'
676
696
  end
677
697
 
678
- private_class_method :find_session_file_with_dir,
698
+ private_class_method :stripped_title, :sanitized_tag, :find_session_file_with_dir,
679
699
  :find_in_directory, :try_project_dir, :find_in_all_projects,
680
700
  :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
681
701
  :append_to_session, :append_to_session_in_directory,
682
- :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
702
+ :append_to_session_global, :own_append, :try_append, :sanitize_unicode, :unicode_category,
683
703
  :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries,
684
704
  :ensure_store_session_exists
685
705
  end