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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/README.md +17 -8
- data/docs/cli-installer.md +16 -2
- data/docs/client.md +44 -4
- data/docs/errors.md +15 -1
- data/docs/hooks-and-permissions.md +27 -3
- data/docs/mcp-servers.md +36 -7
- data/docs/rails.md +3 -4
- data/docs/sessions.md +149 -34
- data/docs/types.md +106 -4
- data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
- data/lib/claude_agent_sdk/cli_installer.rb +68 -11
- data/lib/claude_agent_sdk/command_builder.rb +109 -98
- data/lib/claude_agent_sdk/deprecation.rb +90 -0
- data/lib/claude_agent_sdk/errors.rb +8 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
- data/lib/claude_agent_sdk/message_parser.rb +23 -9
- data/lib/claude_agent_sdk/observer.rb +2 -1
- data/lib/claude_agent_sdk/option_warnings.rb +2 -2
- data/lib/claude_agent_sdk/query.rb +99 -51
- data/lib/claude_agent_sdk/railtie.rb +14 -3
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
- data/lib/claude_agent_sdk/session_mutations.rb +28 -16
- data/lib/claude_agent_sdk/session_resume.rb +39 -35
- data/lib/claude_agent_sdk/session_store.rb +35 -21
- data/lib/claude_agent_sdk/session_summary.rb +12 -5
- data/lib/claude_agent_sdk/sessions.rb +112 -24
- data/lib/claude_agent_sdk/streaming.rb +1 -1
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
- data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
- data/lib/claude_agent_sdk/types/attributes.rb +271 -0
- data/lib/claude_agent_sdk/types/base.rb +320 -0
- data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
- data/lib/claude_agent_sdk/types/hooks.rb +640 -0
- data/lib/claude_agent_sdk/types/mcp.rb +232 -0
- data/lib/claude_agent_sdk/types/messages.rb +614 -0
- data/lib/claude_agent_sdk/types/option_values.rb +302 -0
- data/lib/claude_agent_sdk/types/options.rb +352 -0
- data/lib/claude_agent_sdk/types/permissions.rb +107 -0
- data/lib/claude_agent_sdk/types/sessions.rb +10 -0
- data/lib/claude_agent_sdk/types.rb +13 -2534
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +308 -73
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
- 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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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,
|
|
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' } #
|
|
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:
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
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.
|
|
88
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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:
|
|
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;
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
122
|
+
raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id)
|
|
119
123
|
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
100
|
-
# locate the projects dir (already repointed at the temp dir when
|
|
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
|
-
|
|
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
|
-
|
|
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, &
|
|
297
|
-
FiberBoundary.invoke(timeout: timeout_s, scheduling: scheduling, wrapper: wrapper, &
|
|
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
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
|
|
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
|
-
|
|
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}",
|
|
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
|
|
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, :
|
|
782
|
+
:redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry,
|
|
779
783
|
:encode_agent_metadata
|
|
780
784
|
end
|
|
781
785
|
end
|