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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +90 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +29 -11
- data/docs/configuration.md +164 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +228 -77
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +227 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +35 -5
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +94 -46
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +11 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- 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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
|
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
|
|
448
|
-
# validates against the
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
-
#
|
|
31
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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?
|
|
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 >
|
|
437
|
-
# prompt, suffixed with " (fork)" when derived. listSessions
|
|
438
|
-
# custom-title from the tail, so this trailer is what
|
|
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
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
479
|
-
#
|
|
480
|
-
#
|
|
481
|
-
#
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
507
|
-
#
|
|
508
|
-
#
|
|
509
|
-
#
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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 =
|
|
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 &&
|
|
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 &&
|
|
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
|