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
@@ -6,14 +6,25 @@ module ClaudeAgentSDK
6
6
  # `require 'rails'`), so non-Rails processes never see it.
7
7
  #
8
8
  # Deliberately minimal: it contributes the `claude_agent_sdk:*` rake tasks
9
- # and nothing else. It installs nothing into callback dispatch — the
10
- # generated initializer (`bin/rails g claude_agent_sdk:install`) opts in
11
- # to {.callback_wrapper} explicitly, where it is visible and removable.
9
+ # and anchors CLI discovery to the app root, nothing else. It installs
10
+ # nothing into callback dispatch — the generated initializer
11
+ # (`bin/rails g claude_agent_sdk:install`) opts in to {.callback_wrapper}
12
+ # explicitly, where it is visible and removable.
12
13
  class Railtie < ::Rails::Railtie
13
14
  rake_tasks do
14
15
  load File.expand_path('tasks/claude_agent_sdk.rake', __dir__)
15
16
  end
16
17
 
18
+ # Find the vendored CLI under Rails.root/vendor/claude whatever the
19
+ # process cwd — a daemonized worker or a job runner started elsewhere
20
+ # would otherwise look under its own cwd and fall through to PATH.
21
+ # Runs before config/initializers, so an app initializer can still set
22
+ # CLIInstaller.root (or nil, for the cwd) itself; a root set earlier,
23
+ # e.g. in config/application.rb, is left alone.
24
+ initializer 'claude_agent_sdk.cli_installer_root', before: :load_config_initializers do |app|
25
+ ClaudeAgentSDK::CLIInstaller.root ||= app.root
26
+ end
27
+
17
28
  # A `callback_wrapper` (see ClaudeAgentOptions#callback_wrapper) that
18
29
  # gives SDK callbacks Rails' connection hygiene without deadlocking
19
30
  # development code reloading.
@@ -4,6 +4,7 @@ require 'mcp'
4
4
 
5
5
  module ClaudeAgentSDK
6
6
  # Recursively convert all hash keys to symbols
7
+ # @api private
7
8
  def self.deep_symbolize_keys(obj)
8
9
  case obj
9
10
  when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize_keys(v) }
@@ -15,6 +16,7 @@ module ClaudeAgentSDK
15
16
  # Like deep_symbolize_keys, but also converts Symbol VALUES to strings so a
16
17
  # prebuilt schema written with symbols ({ type: :object, ... }) emits clean
17
18
  # wire-format JSON Schema.
19
+ # @api private
18
20
  def self.deep_normalize_schema(obj)
19
21
  case obj
20
22
  when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_normalize_schema(v) }
@@ -34,6 +36,7 @@ module ClaudeAgentSDK
34
36
  # mangled into nonsense parameter lists ("additionalProperties" as a
35
37
  # required string param). A $ref-only schema without type: 'object' remains
36
38
  # indistinguishable from a params hash — declare the type alongside $ref.
39
+ # @api private
37
40
  def self.prebuilt_json_schema?(schema)
38
41
  return false unless schema.is_a?(Hash)
39
42
 
@@ -47,6 +50,7 @@ module ClaudeAgentSDK
47
50
  # Single source of truth for tool input schemas: prebuilt schemas are
48
51
  # normalized (symbol keys, string values); simple { name: :type } hashes
49
52
  # become a full JSON Schema with every param required (string keys).
53
+ # @api private
50
54
  def self.normalize_tool_schema(schema)
51
55
  return deep_normalize_schema(schema) if prebuilt_json_schema?(schema)
52
56
 
@@ -60,34 +64,29 @@ module ClaudeAgentSDK
60
64
  { type: 'object', properties: {} }
61
65
  end
62
66
 
67
+ # @api private
63
68
  def self.ruby_type_to_json_schema(type)
64
69
  # Class#=== matches instances, not the class object used in { id: Integer }.
65
- type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
70
+ type = { String => :string, Integer => :integer, Float => :float,
71
+ TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
66
72
  case type
67
73
  when :string, String then { type: 'string' }
68
74
  when :integer, Integer then { type: 'integer' }
69
75
  when :float, Float, :number then { type: 'number' }
70
76
  when :boolean, TrueClass, FalseClass then { type: 'boolean' }
71
- else { type: 'string' } # Default fallback
77
+ else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
72
78
  end
73
79
  end
74
80
 
75
- # Internal: call a tool handler, reporting SystemExit / SignalException
76
- # (Interrupt included) as an ordinary handler failure — re-raised as a
77
- # RuntimeError (#cause holds the original) that both tools/call dispatch
78
- # boundaries turn into an in-band isError result, so the pending control
79
- # response is always written. Must run INSIDE the FiberBoundary.invoke
80
- # block: a worker thread that dies with SystemExit has it re-raised by
81
- # Ruby on the MAIN thread, tearing down the reactor, while the dispatcher
82
- # only sees Async::Stop — a rescue after the hop cannot catch it in
83
- # :thread mode. A callback_wrapper therefore observes the RuntimeError.
84
- # Deliberately not `rescue Exception`: cancellation (Async::Stop, and
85
- # InlineCancellation at an :inline suspension point) must propagate.
81
+ # Internal: expand a tool handler's String shorthand into a single text
82
+ # block. Every other value passes through untouched — Hash results behave
83
+ # exactly as before, and any other non-Hash value still gets the "must
84
+ # return a hash" diagnostic from the caller. Applied inside the callback
85
+ # dispatch at both tools/call paths, so a callback_wrapper sees the
86
+ # expanded Hash.
86
87
  # @api private
87
- def self.call_tool_handler(handler, arguments)
88
- handler.call(arguments)
89
- rescue SystemExit, SignalException => e
90
- raise e.message
88
+ def self.normalize_tool_result(result)
89
+ result.is_a?(String) ? { content: [{ type: 'text', text: result }] } : result
91
90
  end
92
91
 
93
92
  # SDK MCP Server - wraps official MCP::Server with block-based API
@@ -98,14 +97,17 @@ module ClaudeAgentSDK
98
97
  #
99
98
  # This class wraps the official MCP Ruby SDK and provides a simpler block-based
100
99
  # API for defining tools, resources, and prompts.
101
- class SdkMcpServer
100
+ class SdkMcpServer # rubocop:disable Metrics/ClassLength -- one facade over MCP::Server tools, resources and prompts
102
101
  # The gem validates arguments before injecting its server_context keyword.
103
102
  # Guard actual keys here, independent of schema composition/$ref support,
104
103
  # and retain this guard even when schema validation falls back to permissive.
104
+ #
105
+ # @api private
105
106
  class ToolInputSchema < MCP::Tool::InputSchema
106
107
  def validate_arguments(arguments)
107
108
  if arguments.is_a?(Hash) && (arguments.key?(:server_context) || arguments.key?('server_context'))
108
- raise ValidationError, "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
109
+ raise ValidationError,
110
+ "Tool argument 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
109
111
  end
110
112
 
111
113
  super
@@ -152,7 +154,9 @@ module ClaudeAgentSDK
152
154
  # Validated at set time so a non-callable fails here, not later as a
153
155
  # NoMethodError inside a tool dispatch.
154
156
  def callback_wrapper=(value)
155
- raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})" unless value.nil? || value.respond_to?(:call)
157
+ unless value.nil? || value.respond_to?(:call)
158
+ raise ArgumentError, "callback_wrapper must be a callable or nil (got #{value.inspect})"
159
+ end
156
160
 
157
161
  @callback_wrapper = value
158
162
  end
@@ -226,6 +230,7 @@ module ClaudeAgentSDK
226
230
  # Handle a JSON-RPC request
227
231
  # @param json_string [String] JSON-RPC request
228
232
  # @return [String] JSON-RPC response
233
+ # @api private
229
234
  def handle_json(json_string)
230
235
  @mcp_server.handle_json(json_string)
231
236
  end
@@ -247,6 +252,7 @@ module ClaudeAgentSDK
247
252
  # Responses are built from per-call locals (safe), but the gem's
248
253
  # instrumentation_callback attribution (@instrumentation_data ivar) can
249
254
  # cross-contaminate under concurrency — harmless with the default no-op.
255
+ # @api private
250
256
  def handle_message(message)
251
257
  original_id = message[:id]
252
258
  response = @mcp_server.handle(message.merge(jsonrpc: '2.0', id: 0))
@@ -289,12 +295,14 @@ module ClaudeAgentSDK
289
295
  # gem's Fiber scheduler is not visible to user code (which may hit
290
296
  # AR/PG); in :inline mode it runs in place on the reactor fiber.
291
297
  scheduling, wrapper = effective_callback_dispatch
292
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
293
- ClaudeAgentSDK.call_tool_handler(tool.handler, arguments)
298
+ # exit / Interrupt from the handler propagate (never an isError
299
+ # result): see FiberBoundary.invoke_callback.
300
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
301
+ ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments))
294
302
  end
295
303
 
296
304
  # Guard before flexible_fetch: it raises on non-Hash inputs.
297
- content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "content", "content") : nil
305
+ content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil
298
306
  return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
299
307
 
300
308
  result
@@ -327,13 +335,13 @@ module ClaudeAgentSDK
327
335
  # as `call_tool` above: reader blocks may touch Thread.current-keyed
328
336
  # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
329
337
  scheduling, wrapper = effective_callback_dispatch
330
- content = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
338
+ content = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
331
339
  resource.reader.call
332
340
  end
333
341
 
334
342
  # Ensure content has the expected format (symbol or string keys; guard
335
343
  # before flexible_fetch — it raises on non-Hash inputs)
336
- contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, "contents", "contents") : nil
344
+ contents = content.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(content, 'contents', 'contents') : nil
337
345
  raise "Resource '#{uri}' must return a hash with :contents key" if contents.nil?
338
346
 
339
347
  content
@@ -362,12 +370,12 @@ module ClaudeAgentSDK
362
370
  # Hop off the Fiber scheduler before invoking user code — same reason
363
371
  # as `call_tool` above.
364
372
  scheduling, wrapper = effective_callback_dispatch
365
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
373
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
366
374
  prompt.generator.call(arguments)
367
375
  end
368
376
 
369
377
  # Ensure result has the expected format (symbol or string keys)
370
- messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, "messages", "messages") : nil
378
+ messages = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'messages', 'messages') : nil
371
379
  raise "Prompt '#{name}' must return a hash with :messages key" if messages.nil?
372
380
 
373
381
  result
@@ -379,7 +387,7 @@ module ClaudeAgentSDK
379
387
  # in content with isError: true, returned as a *successful* JSON-RPC
380
388
  # result.
381
389
  def error_tool_result(text)
382
- { content: [{ type: "text", text: text }], isError: true }
390
+ { content: [{ type: 'text', text: text }], isError: true }
383
391
  end
384
392
 
385
393
  # The mcp gem's tools/call error behavior swung across 0.x releases:
@@ -404,18 +412,19 @@ module ClaudeAgentSDK
404
412
  end
405
413
 
406
414
  # Create dynamic Tool classes from tool definitions
407
- def create_tool_classes(tools)
415
+ def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
408
416
  # Captured so the dynamic class can resolve the effective scheduling
409
417
  # mode at call time — same pattern as prompt classes.
410
418
  sdk_server = self
411
- tools.map do |tool_def|
419
+ tools.map do |tool_def| # rubocop:disable Metrics/BlockLength -- see create_tool_classes
412
420
  # The gem injects server_context AFTER expanding the tool arguments,
413
421
  # overwriting a user value before our call method can recover it.
414
422
  # Check at registration (including raw SdkMcpTool definitions), not in
415
423
  # input_schema_value's permissive schema-error fallback.
416
424
  schema = ClaudeAgentSDK.normalize_tool_schema(tool_def.input_schema)
417
425
  if schema[:properties]&.key?(:server_context)
418
- raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; rename it (e.g. 'request_context')"
426
+ raise ArgumentError, "Tool '#{tool_def.name}' input property 'server_context' is reserved by the MCP SDK; " \
427
+ "rename it (e.g. 'request_context')"
419
428
  end
420
429
 
421
430
  # Create a new class that extends MCP::Tool
@@ -470,13 +479,16 @@ module ClaudeAgentSDK
470
479
  @tool_def.meta
471
480
  end
472
481
 
473
- def call(server_context: nil, **args)
482
+ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgument -- declared to strip it from args
474
483
  # Filter out server_context and pass remaining args to handler.
475
484
  # Hop to a plain thread (default) so user handlers don't see
476
485
  # the Fiber scheduler; :inline runs in place on the reactor.
477
486
  scheduling, wrapper = @sdk_server.effective_callback_dispatch
478
- result = FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) do
479
- ClaudeAgentSDK.call_tool_handler(@tool_def.handler, args)
487
+ # exit / Interrupt propagate past the gem (it rescues only
488
+ # StandardError) to Query#handle_control_request, which
489
+ # answers with an isError result and then re-raises them.
490
+ result = FiberBoundary.invoke_callback(scheduling: scheduling, wrapper: wrapper) do
491
+ ClaudeAgentSDK.normalize_tool_result(@tool_def.handler.call(args))
480
492
  end
481
493
 
482
494
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
@@ -595,18 +607,26 @@ module ClaudeAgentSDK
595
607
  # @param name [String] Unique identifier for the tool
596
608
  # @param description [String] Human-readable description
597
609
  # @param input_schema [Hash] Schema defining input parameters
598
- # @param handler [Proc] Block that implements the tool logic
610
+ # @param handler [Proc] Block that implements the tool logic. It returns a
611
+ # String, sent to Claude as a single text block, or a Hash with a
612
+ # +:content+ Array of MCP content blocks plus optional +:is_error+ /
613
+ # +:structured_content+. Use the Hash form for error results, structured
614
+ # output, images, or several blocks.
599
615
  # @return [SdkMcpTool] Tool definition
600
616
  #
601
- # @example Simple tool
617
+ # @example Simple tool (a String return becomes one text block)
618
+ # tool = create_tool('greet', 'Greet a user', { name: :string }) do |args|
619
+ # "Hello, #{args[:name]}!"
620
+ # end
621
+ #
622
+ # @example The same tool in the Hash form
602
623
  # tool = create_tool('greet', 'Greet a user', { name: :string }) do |args|
603
624
  # { content: [{ type: 'text', text: "Hello, #{args[:name]}!" }] }
604
625
  # end
605
626
  #
606
627
  # @example Tool with multiple parameters
607
628
  # tool = create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args|
608
- # result = args[:a] + args[:b]
609
- # { content: [{ type: 'text', text: "Result: #{result}" }] }
629
+ # "Result: #{args[:a] + args[:b]}"
610
630
  # end
611
631
  #
612
632
  # @example Tool with error handling
@@ -13,7 +13,9 @@ module ClaudeAgentSDK
13
13
  # Ported from Python SDK's _internal/session_mutations.py.
14
14
  # Appends typed metadata entries to the session's JSONL file,
15
15
  # matching the CLI pattern. Safe to call from any SDK host process.
16
- module SessionMutations # rubocop:disable Metrics/ModuleLength
16
+ #
17
+ # @api private
18
+ module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/delete/fork share transcript helpers
17
19
  module_function
18
20
 
19
21
  # Transcript entry types kept in fork output. Mirrors Python's
@@ -34,7 +36,7 @@ module ClaudeAgentSDK
34
36
  # @raise [ArgumentError] if session_id is invalid or title is empty
35
37
  # @raise [Errno::ENOENT] if the session file cannot be found
36
38
  def rename_session(session_id:, title:, directory: nil)
37
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
39
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
38
40
 
39
41
  stripped = title.strip
40
42
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
@@ -55,7 +57,7 @@ module ClaudeAgentSDK
55
57
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
56
58
  # @raise [Errno::ENOENT] if the session file cannot be found
57
59
  def tag_session(session_id:, tag:, directory: nil)
58
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
60
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
59
61
 
60
62
  if tag
61
63
  sanitized = sanitize_unicode(tag).strip
@@ -79,10 +81,12 @@ module ClaudeAgentSDK
79
81
  # @raise [ArgumentError] if session_id is invalid
80
82
  # @raise [Errno::ENOENT] if the session file cannot be found
81
83
  def delete_session(session_id:, directory: nil)
82
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
84
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
83
85
 
84
86
  result = find_session_file_with_dir(session_id, directory)
85
- raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
87
+ unless result
88
+ raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
89
+ end
86
90
 
87
91
  path = result[0]
88
92
 
@@ -115,12 +119,16 @@ module ClaudeAgentSDK
115
119
  # @raise [ArgumentError] if session_id or up_to_message_id is invalid
116
120
  # @raise [Errno::ENOENT] if the session file cannot be found
117
121
  def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
118
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
122
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
119
123
 
120
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
124
+ if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
125
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
126
+ end
121
127
 
122
128
  result = find_session_file_with_dir(session_id, directory)
123
- raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
129
+ unless result
130
+ raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}"
131
+ end
124
132
 
125
133
  file_path, project_dir = result
126
134
  file_size = File.size(file_path)
@@ -159,7 +167,7 @@ module ClaudeAgentSDK
159
167
  # @raise [ArgumentError] if session_id is invalid or title is empty
160
168
  # @raise [Errno::ENOENT] if the session is not found in the store
161
169
  def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
162
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
170
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
163
171
 
164
172
  stripped = title.strip
165
173
  raise ArgumentError, 'title must be non-empty' if stripped.empty?
@@ -183,7 +191,7 @@ module ClaudeAgentSDK
183
191
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
184
192
  # @raise [Errno::ENOENT] if the session is not found in the store
185
193
  def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
186
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
194
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
187
195
 
188
196
  if tag
189
197
  sanitized = sanitize_unicode(tag).strip
@@ -213,7 +221,7 @@ module ClaudeAgentSDK
213
221
  #
214
222
  # @raise [ArgumentError] if session_id is invalid
215
223
  def delete_session_via_store(session_store:, session_id:, directory: nil)
216
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
224
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
217
225
  return unless SessionStore.implements?(session_store, :delete)
218
226
 
219
227
  key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
@@ -231,8 +239,10 @@ module ClaudeAgentSDK
231
239
  # @raise [ArgumentError] if session_id/up_to_message_id is invalid or the session has no messages
232
240
  # @raise [Errno::ENOENT] if the source session is not found in the store
233
241
  def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
- raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
235
- raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
242
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
243
+ if up_to_message_id && !Sessions.valid_session_id?(up_to_message_id)
244
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}"
245
+ end
236
246
 
237
247
  project_key = Sessions.project_key_for_directory(directory)
238
248
  raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
@@ -382,7 +392,7 @@ module ClaudeAgentSDK
382
392
  # +derive_title+ is a callable invoked ONLY when no explicit +title+ is
383
393
  # given, so the disk path's head/tail byte scan and the store path's
384
394
  # entry-object scan each run only when needed.
385
- def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
395
+ def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- single fork rewrite pass: UUID remap, truncation, title
386
396
  transcript = transcript.reject { |e| e['isSidechain'] }
387
397
  raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
388
398
 
@@ -520,7 +530,7 @@ module ClaudeAgentSDK
520
530
  end
521
531
 
522
532
  # Build a single forked entry with remapped UUIDs.
523
- def build_forked_entry(original, index, total, uuid_mapping, by_uuid,
533
+ def build_forked_entry(original, index, total, uuid_mapping, by_uuid, # rubocop:disable Metrics/ParameterLists -- per-entry step of build_fork_lines; its state is threaded explicitly
524
534
  forked_session_id, source_session_id, now)
525
535
  new_uuid = uuid_mapping[original['uuid']]
526
536
 
@@ -603,7 +613,9 @@ module ClaudeAgentSDK
603
613
 
604
614
  def append_to_session_global(session_id, data, file_name)
605
615
  projects_dir = File.join(Sessions.config_dir, 'projects')
606
- raise Errno::ENOENT, "Session #{session_id} not found (no projects directory)" unless File.directory?(projects_dir)
616
+ unless File.directory?(projects_dir)
617
+ raise Errno::ENOENT, "Session #{session_id} not found (no projects directory)"
618
+ end
607
619
 
608
620
  found = Dir.children(projects_dir).any? do |child|
609
621
  candidate = File.join(projects_dir, child, file_name)
@@ -17,6 +17,8 @@ module ClaudeAgentSDK
17
17
  # +config_dir+ is a temp directory laid out like ~/.claude/ — point the
18
18
  # subprocess at it via CLAUDE_CONFIG_DIR. +resume_session_id+ is passed as
19
19
  # --resume. Call #cleanup after the subprocess exits to remove the temp dir.
20
+ #
21
+ # @api private
20
22
  class MaterializedResume
21
23
  attr_reader :config_dir, :resume_session_id
22
24
 
@@ -40,7 +42,7 @@ module ClaudeAgentSDK
40
42
  ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
41
43
  FileUtils.rm_f(File.join(@config_dir, name))
42
44
  end
43
- warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
45
+ warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \
44
46
  "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
45
47
  'deleting it — import it into your session store, then remove the directory.'
46
48
  rescue StandardError => e
@@ -55,7 +57,9 @@ module ClaudeAgentSDK
55
57
  # store. The CLI only resumes from a local file. This module loads the session
56
58
  # from the store, writes it to a temp dir laid out like ~/.claude/, and returns
57
59
  # the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
58
- module SessionResume # rubocop:disable Metrics/ModuleLength
60
+ #
61
+ # @api private
62
+ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materialization and its helpers
59
63
  # User settings files seeded into the temp config dir. cowork_settings.json
60
64
  # is the alternate filename the CLI reads in cowork-plugins mode.
61
65
  SEEDED_SETTINGS_FILES = ['settings.json', 'cowork_settings.json'].freeze
@@ -96,9 +100,9 @@ module ClaudeAgentSDK
96
100
  # Build a TranscriptMirrorBatcher for a configured session_store. Shared by
97
101
  # both entry points (Client#install_transcript_mirror and the one-shot
98
102
  # 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
103
+ # live in one place. +env+ supplies the CLAUDE_CONFIG_DIR / HOME overrides
104
+ # used to locate the projects dir (already repointed at the temp dir when
105
+ # resuming from a store). Eager flush mode zeroes the buffer thresholds so every
102
106
  # transcript_mirror frame triggers a background flush.
103
107
  def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper: nil)
104
108
  TranscriptMirrorBatcher.new(
@@ -116,7 +120,7 @@ module ClaudeAgentSDK
116
120
  # (no store, no resume/continue, store has no entries, or the resolved
117
121
  # session id is not a valid UUID) — the caller then falls through to the
118
122
  # normal spawn path. Raises RuntimeError if a store call fails or times out.
119
- def materialize_resume_session(options)
123
+ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order
120
124
  store = options.session_store
121
125
  return nil if store.nil?
122
126
  return nil if options.resume.nil? && !options.continue_conversation
@@ -155,7 +159,9 @@ module ClaudeAgentSDK
155
159
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
156
160
  copy_auth_files(tmp_base, options.env)
157
161
 
158
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) if SessionStore.implements?(store, :list_subkeys)
162
+ if SessionStore.implements?(store, :list_subkeys)
163
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
164
+ end
159
165
  rescue Exception # rubocop:disable Lint/RescueException
160
166
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
161
167
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -172,7 +178,7 @@ module ClaudeAgentSDK
172
178
 
173
179
  # Load entries for session_id; return [session_id, entries] or nil if empty.
174
180
  # Callers pass the result through encode_candidate before writing.
175
- def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper)
181
+ def load_candidate(store, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
176
182
  entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id}", scheduling, wrapper) do
177
183
  store.load('project_key' => project_key, 'session_id' => session_id)
178
184
  end
@@ -185,7 +191,7 @@ module ClaudeAgentSDK
185
191
  # transcripts are mirrored as ordinary top-level keys and often have the
186
192
  # highest mtime, so walk newest->oldest and skip them so --continue resumes
187
193
  # the user's conversation, not a subagent's.
188
- def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper)
194
+ def resolve_continue_candidate(store, project_key, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- newest-first walk with sidechain and validity skips
189
195
  sessions = with_timeout(timeout_s, 'SessionStore#list_sessions', scheduling, wrapper) do
190
196
  store.list_sessions(project_key)
191
197
  end
@@ -193,7 +199,10 @@ module ClaudeAgentSDK
193
199
 
194
200
  sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
195
201
 
196
- sessions.sort_by { |s| -Sessions.sortable_mtime(s['mtime']) }.each do |cand|
202
+ # Same order as the listings (#78): newest first, equal mtimes by
203
+ # session_id — sort_by is unstable, so an mtime-only key let equal
204
+ # mtimes resume whichever session the adapter happened to list first.
205
+ sessions.sort_by { |s| Sessions.listing_sort_key(s['mtime'], s['session_id']) }.each do |cand|
197
206
  sid = cand['session_id']
198
207
  next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
199
208
  # Skip known sidechains without downloading their transcript: the
@@ -293,8 +302,8 @@ module ClaudeAgentSDK
293
302
  # thread-hop bound still applies. The session's callback_wrapper
294
303
  # composes inside the bound (see FiberBoundary.invoke); a wrapper-raised
295
304
  # 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)
305
+ def with_timeout(timeout_s, what, scheduling = :thread, wrapper = nil, &)
306
+ FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &)
298
307
  rescue FiberBoundary::JoinTimeout
299
308
  raise "#{what} timed out after #{(timeout_s * 1000).to_i}ms during resume materialization"
300
309
  rescue RuntimeError
@@ -326,13 +335,17 @@ module ClaudeAgentSDK
326
335
  # .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
327
336
  # ~/.claude.json (NOT ~/.claude/.claude.json).
328
337
  #
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.
333
- def copy_auth_files(tmp_base, opt_env)
338
+ # Both are resolved as the CHILD will see them: CLAUDE_CONFIG_DIR via
339
+ # env_value, and "~" as the HOME in options.env when it sets one (see
340
+ # Sessions.home_dir) — seeding from the parent's home would copy another
341
+ # user's credentials and settings than the ones the CLI would have read.
342
+ # Without a usable home the home-relative sources are skipped like
343
+ # missing files: they cannot exist, and raising here aborted every
344
+ # store-backed resume on a HOME-less host — even API-key auth, which
345
+ # needs none of them.
346
+ def copy_auth_files(tmp_base, opt_env) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- each auth source is optional and copied independently
334
347
  caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
335
- home = caller_config_dir ? nil : home_dir
348
+ home = caller_config_dir ? nil : Sessions.home_dir(opt_env)
336
349
  source_config_dir = caller_config_dir || (home && File.join(home, '.claude'))
337
350
 
338
351
  # read_if_present returns raw bytes; the credentials path parses and
@@ -354,7 +367,9 @@ module ClaudeAgentSDK
354
367
  write_redacted_credentials(creds_json, File.join(tmp_base, '.credentials.json'))
355
368
 
356
369
  claude_json_dir = caller_config_dir || home
357
- copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json')) if claude_json_dir
370
+ if claude_json_dir
371
+ copy_if_present(File.join(claude_json_dir, '.claude.json'), File.join(tmp_base, '.claude.json'))
372
+ end
358
373
 
359
374
  # User settings carry apiKeyHelper (a fourth auth mechanism alongside
360
375
  # .credentials.json / Keychain / env vars) plus the user's env, hooks and
@@ -577,7 +592,7 @@ module ClaudeAgentSDK
577
592
  end
578
593
 
579
594
  # Load and write all subagent transcripts/metadata under session_id.
580
- def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
595
+ def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
581
596
  session_dir = File.join(project_dir, session_id)
582
597
  subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
583
598
  store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
@@ -591,7 +606,8 @@ module ClaudeAgentSDK
591
606
  next
592
607
  end
593
608
 
594
- sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}", scheduling, wrapper) do
609
+ sub_entries = with_timeout(timeout_s, "SessionStore#load for session #{session_id} subpath #{subpath}",
610
+ scheduling, wrapper) do
595
611
  store.load('project_key' => project_key, 'session_id' => session_id, 'subpath' => subpath)
596
612
  end
597
613
  next if sub_entries.nil? || sub_entries.empty?
@@ -627,7 +643,7 @@ module ClaudeAgentSDK
627
643
  def encode_agent_metadata(meta_content, subpath)
628
644
  JSON.generate(meta_content)
629
645
  rescue JSON::JSONError => e
630
- warn "Claude SDK: [SessionStore] resume: skipping unserializable agent metadata " \
646
+ warn 'Claude SDK: [SessionStore] resume: skipping unserializable agent metadata ' \
631
647
  "for subpath #{subpath} (#{e.class}: #{e.message})"
632
648
  nil
633
649
  end
@@ -758,24 +774,12 @@ module ClaudeAgentSDK
758
774
  value && (!value.respond_to?(:empty?) || !value.empty?) ? value : nil
759
775
  end
760
776
 
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
777
  private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
774
778
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
775
779
  :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
776
780
  :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
777
781
  :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
778
- :redacted_credentials, :home_dir, :encode_candidate, :encode_jsonl_lines, :encode_entry,
782
+ :redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry,
779
783
  :encode_agent_metadata
780
784
  end
781
785
  end