claude-agent-sdk 1.1.0 → 1.2.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +90 -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 +84 -27
  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/query.rb +228 -77
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  37. data/lib/claude_agent_sdk/types/options.rb +35 -5
  38. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +94 -46
  41. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  42. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  43. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  44. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  45. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  46. metadata +6 -4
@@ -36,6 +36,9 @@ module ClaudeAgentSDK
36
36
  # mangled into nonsense parameter lists ("additionalProperties" as a
37
37
  # required string param). A $ref-only schema without type: 'object' remains
38
38
  # indistinguishable from a params hash — declare the type alongside $ref.
39
+ # For the same reason { type: :object } is this prebuilt accept-any-object
40
+ # schema even though :object is also a shorthand type: a simple schema with
41
+ # an object parameter literally named type has to spell it { type: Hash }.
39
42
  # @api private
40
43
  def self.prebuilt_json_schema?(schema)
41
44
  return false unless schema.is_a?(Hash)
@@ -67,13 +70,16 @@ module ClaudeAgentSDK
67
70
  # @api private
68
71
  def self.ruby_type_to_json_schema(type)
69
72
  # Class#=== matches instances, not the class object used in { id: Integer }.
70
- type = { String => :string, Integer => :integer, Float => :float,
71
- TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
73
+ type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean,
74
+ FalseClass => :boolean, Array => :array, Hash => :object }.fetch(type, type)
72
75
  case type
73
76
  when :string, String then { type: 'string' }
74
77
  when :integer, Integer then { type: 'integer' }
75
78
  when :float, Float, :number then { type: 'number' }
76
79
  when :boolean, TrueClass, FalseClass then { type: 'boolean' }
80
+ # The class or the Symbol only. An Array or Hash VALUE ([String], a nested
81
+ # schema fragment) is not a shorthand and falls through like before.
82
+ when :array, :object then { type: type.to_s }
77
83
  else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
78
84
  end
79
85
  end
@@ -301,13 +307,25 @@ module ClaudeAgentSDK
301
307
  ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments))
302
308
  end
303
309
 
304
- # Guard before flexible_fetch: it raises on non-Hash inputs.
305
- content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil
306
- return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
310
+ # Guard before flexible_fetch: it raises on non-Hash inputs. This first
311
+ # diagnostic is about the KEY: a present false or nil is a wrong value
312
+ # and gets the Array diagnostic below, like any other non-Array.
313
+ unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
314
+ return error_tool_result("Tool '#{name}' must return a hash with :content key")
315
+ end
316
+
317
+ content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
318
+ # A String or a single block here is not a result an MCP client accepts.
319
+ unless content.is_a?(Array)
320
+ return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \
321
+ "(got #{content.class})")
322
+ end
307
323
 
308
324
  result
309
- rescue StandardError => e
310
- # Bare e.message like Python's str(e) — no prefix.
325
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
326
+ # Bare e.message like Python's str(e) — no prefix. The list, not just
327
+ # StandardError: a handler's NotImplementedError, LoadError or
328
+ # SystemStackError is a tool failure the model should read as well.
311
329
  error_tool_result(e.message)
312
330
  end
313
331
 
@@ -412,7 +430,7 @@ module ClaudeAgentSDK
412
430
  end
413
431
 
414
432
  # Create dynamic Tool classes from tool definitions
415
- def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
433
+ def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds each dynamic MCP::Tool subclass inline
416
434
  # Captured so the dynamic class can resolve the effective scheduling
417
435
  # mode at call time — same pattern as prompt classes.
418
436
  sdk_server = self
@@ -444,16 +462,22 @@ module ClaudeAgentSDK
444
462
  end
445
463
 
446
464
  def input_schema_value
447
- # Full-schema construction: the gem JSON-round-trips and
448
- # validates against the draft4 metaschema. additionalProperties/
449
- # enum/description survive. Empty required arrays are stripped —
450
- # draft4's metaschema mandates non-empty required (Python's
451
- # modern jsonschema accepts []). Schemas the draft4 metaschema
452
- # rejects (numeric exclusiveMinimum, $ref/$defs — valid modern
453
- # JSON Schema that Python accepts) fall back to a permissive
454
- # schema with a one-time warning: the tool keeps working with
455
- # argument validation disabled instead of being permanently
456
- # uncallable while tools/list advertises it as healthy.
465
+ # Full-schema construction: the gem JSON-round-trips the schema
466
+ # and validates it against the JSON Schema 2020-12 metaschema
467
+ # (every mcp version this gem supports; older ones used draft4).
468
+ # additionalProperties/enum/description survive, and so do a
469
+ # numeric exclusiveMinimum and same-document $ref/$defs. Empty
470
+ # required arrays are stripped — a holdover from draft4, whose
471
+ # metaschema mandated a non-empty required.
472
+ #
473
+ # A schema the gem refuses (ArgumentError: a draft4-style
474
+ # boolean exclusiveMinimum, an unknown type, a $ref that leaves
475
+ # the document, an invalid pattern, ...) falls back to a
476
+ # permissive schema with a warning, once per tool: the tool
477
+ # keeps working with argument validation disabled instead of
478
+ # being permanently uncallable while tools/list advertises it
479
+ # as healthy. (The warning's "not draft4-compatible" wording
480
+ # dates from the draft4 days.)
457
481
  @input_schema_value ||= begin
458
482
  schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
459
483
  schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
@@ -493,11 +517,21 @@ module ClaudeAgentSDK
493
517
 
494
518
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
495
519
  # TypeError/NoMethodError, surfacing garbage instead of the
496
- # friendly message.
497
- raise "Tool '#{@tool_def.name}' must return a hash with :content key" unless result.is_a?(Hash)
520
+ # friendly message. This first diagnostic is about the KEY: a
521
+ # present false or nil is a wrong value and gets the Array
522
+ # diagnostic below, like any other non-Array.
523
+ unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
524
+ raise "Tool '#{@tool_def.name}' must return a hash with :content key"
525
+ end
498
526
 
499
527
  content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
500
- raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil?
528
+ # A String or a single block Hash would go out as it is, and the
529
+ # CLI rejects that frame against its schema: the model is told
530
+ # the SERVER returned a malformed result and never sees the text.
531
+ unless content.is_a?(Array)
532
+ raise "Tool '#{@tool_def.name}' must return :content as an Array of content blocks " \
533
+ "(got #{content.class})"
534
+ end
501
535
 
502
536
  is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
503
537
  structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
@@ -507,12 +541,15 @@ module ClaudeAgentSDK
507
541
  error: !!is_error,
508
542
  structured_content: structured_content
509
543
  )
510
- rescue StandardError => e
544
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
511
545
  # Report handler failures in-band HERE rather than letting them
512
546
  # reach the gem: mcp >= 1.2 deliberately drops e.message from
513
547
  # its "Internal error calling tool X" wrapper (CWE-209), which
514
548
  # would hide the text the model needs to self-correct. Bare
515
549
  # e.message like Python's str(e) and #call_tool — no prefix.
550
+ # The list, not just StandardError: the gem rescues only
551
+ # StandardError, so a handler's NotImplementedError / LoadError /
552
+ # SystemStackError would pass it unanswered.
516
553
  # Nothing gem-internal can be swallowed here today: handlers get
517
554
  # no server_context, so MCP::CancelledError never originates
518
555
  # inside this method. Revisit if cancellation is ever plumbed in.
@@ -606,7 +643,17 @@ module ClaudeAgentSDK
606
643
  #
607
644
  # @param name [String] Unique identifier for the tool
608
645
  # @param description [String] Human-readable description
609
- # @param input_schema [Hash] Schema defining input parameters
646
+ # @param input_schema [Hash] Schema defining input parameters: a full JSON
647
+ # Schema (+{ type: 'object', properties: ... }+), or the shorthand
648
+ # +{ name: type }+, in which every parameter is required and each type is
649
+ # +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ /
650
+ # +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+
651
+ # or +Hash+ / +:object+
652
+ # @param annotations [Hash, nil] MCP tool annotations (+title+,
653
+ # +readOnlyHint+, ...). +maxResultSizeChars+ is also forwarded as
654
+ # +_meta['anthropic/maxResultSizeChars']+, the form the CLI reads
655
+ # @param meta [Hash, nil] The tool's +_meta+. Merged with the size hint
656
+ # derived from +annotations+; a key given here wins
610
657
  # @param handler [Proc] Block that implements the tool logic. It returns a
611
658
  # String, sent to Claude as a single text block, or a Hash with a
612
659
  # +:content+ Array of MCP content blocks plus optional +:is_error+ /
@@ -641,11 +688,16 @@ module ClaudeAgentSDK
641
688
  def self.create_tool(name, description, input_schema, annotations: nil, meta: nil, &handler)
642
689
  raise ArgumentError, 'Block required for tool handler' unless handler
643
690
 
644
- # Auto-populate _meta with maxResultSizeChars from annotations if present
691
+ # Auto-populate _meta with maxResultSizeChars from annotations if present.
692
+ # An explicit meta: is merged with that hint rather than replacing it (an
693
+ # unrelated key used to drop it); a size key the caller sets itself wins,
694
+ # in either spelling — adding the String key beside a Symbol one would
695
+ # put the same JSON key in the frame twice.
696
+ size_key = 'anthropic/maxResultSizeChars'
645
697
  resolved_meta = meta
646
- if resolved_meta.nil? && annotations
698
+ if annotations.is_a?(Hash) && (meta.nil? || (meta.is_a?(Hash) && meta.keys.none? { |key| key.to_s == size_key }))
647
699
  max_chars = annotations[:maxResultSizeChars] || annotations['maxResultSizeChars']
648
- resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars
700
+ resolved_meta = { size_key => max_chars }.merge(meta || {}) if max_chars
649
701
  end
650
702
 
651
703
  # tools/call arrives from the CLI with the name as a JSON String; the mcp
@@ -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