claude_agent 0.7.14 → 0.7.16
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/.claude/rules/conventions.md +66 -16
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +24 -4
- data/README.md +52 -1529
- data/SPEC.md +56 -29
- data/docs/architecture.md +339 -0
- data/docs/client.md +526 -0
- data/docs/configuration.md +571 -0
- data/docs/conversations.md +461 -0
- data/docs/errors.md +127 -0
- data/docs/events.md +225 -0
- data/docs/getting-started.md +310 -0
- data/docs/hooks.md +380 -0
- data/docs/logging.md +96 -0
- data/docs/mcp.md +308 -0
- data/docs/messages.md +871 -0
- data/docs/permissions.md +611 -0
- data/docs/queries.md +227 -0
- data/docs/sessions.md +335 -0
- data/lib/claude_agent/abort_controller.rb +24 -0
- data/lib/claude_agent/client/commands.rb +32 -0
- data/lib/claude_agent/client.rb +10 -4
- data/lib/claude_agent/configuration.rb +129 -0
- data/lib/claude_agent/control_protocol/commands.rb +28 -0
- data/lib/claude_agent/conversation.rb +37 -4
- data/lib/claude_agent/errors.rb +21 -4
- data/lib/claude_agent/event_handler.rb +14 -0
- data/lib/claude_agent/fork_session.rb +117 -0
- data/lib/claude_agent/hook_registry.rb +110 -0
- data/lib/claude_agent/hooks.rb +4 -0
- data/lib/claude_agent/mcp/server.rb +22 -0
- data/lib/claude_agent/mcp/tool.rb +24 -3
- data/lib/claude_agent/message.rb +93 -0
- data/lib/claude_agent/messages/streaming.rb +37 -0
- data/lib/claude_agent/options.rb +10 -0
- data/lib/claude_agent/permission_policy.rb +174 -0
- data/lib/claude_agent/permission_request.rb +17 -0
- data/lib/claude_agent/session.rb +100 -11
- data/lib/claude_agent/session_paths.rb +5 -2
- data/lib/claude_agent/turn_result.rb +20 -2
- data/lib/claude_agent/types/sessions.rb +8 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +187 -0
- data/sig/claude_agent.rbs +38 -1
- metadata +20 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Shared interface for all message and content block types.
|
|
5
|
+
#
|
|
6
|
+
# Provides a consistent API across the 22+ message types and 8 content
|
|
7
|
+
# block types without interfering with `Data.define` inheritance.
|
|
8
|
+
#
|
|
9
|
+
# @example Universal text extraction
|
|
10
|
+
# message.text_content # works on AssistantMessage, UserMessage, TextBlock, etc.
|
|
11
|
+
#
|
|
12
|
+
# @example Duck-type checks
|
|
13
|
+
# message.session_message? # has uuid + session_id?
|
|
14
|
+
# message.identifiable? # has uuid?
|
|
15
|
+
#
|
|
16
|
+
# @example Pattern matching
|
|
17
|
+
# case message
|
|
18
|
+
# in { type: :assistant }
|
|
19
|
+
# puts message.text_content
|
|
20
|
+
# in { type: :result }
|
|
21
|
+
# puts message.cost
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
module Message
|
|
25
|
+
# Universal text extraction across all message and content block types.
|
|
26
|
+
#
|
|
27
|
+
# @return [String] Extracted text (empty string if no text available)
|
|
28
|
+
def text_content
|
|
29
|
+
case self
|
|
30
|
+
when AssistantMessage
|
|
31
|
+
text
|
|
32
|
+
when UserMessage, UserMessageReplay
|
|
33
|
+
content.is_a?(String) ? content : ""
|
|
34
|
+
when TextBlock
|
|
35
|
+
text
|
|
36
|
+
when ThinkingBlock
|
|
37
|
+
thinking
|
|
38
|
+
when StreamEvent
|
|
39
|
+
delta_text || ""
|
|
40
|
+
when ToolProgressMessage
|
|
41
|
+
""
|
|
42
|
+
when ResultMessage
|
|
43
|
+
""
|
|
44
|
+
when GenericMessage
|
|
45
|
+
raw.is_a?(Hash) ? (raw[:text] || raw["text"] || "").to_s : ""
|
|
46
|
+
else
|
|
47
|
+
respond_to?(:text) ? (text || "").to_s : ""
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this message carries session identity (uuid + session_id).
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def session_message?
|
|
55
|
+
respond_to?(:uuid) && respond_to?(:session_id) &&
|
|
56
|
+
!uuid.nil? && !session_id.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether this message has a UUID.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def identifiable?
|
|
63
|
+
respond_to?(:uuid) && !uuid.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Override deconstruct_keys to include :type for pattern matching.
|
|
67
|
+
#
|
|
68
|
+
# Allows `case msg; in { type: :assistant }` to work naturally.
|
|
69
|
+
#
|
|
70
|
+
# Data#deconstruct_keys stops early if it encounters a non-member key,
|
|
71
|
+
# so we filter out :type (a virtual key) before delegating to super.
|
|
72
|
+
#
|
|
73
|
+
# @param keys [Array<Symbol>, nil]
|
|
74
|
+
# @return [Hash]
|
|
75
|
+
def deconstruct_keys(keys)
|
|
76
|
+
if keys.nil?
|
|
77
|
+
{ type: type }.merge(super)
|
|
78
|
+
elsif keys.include?(:type)
|
|
79
|
+
member_keys = keys - [ :type ]
|
|
80
|
+
base = member_keys.empty? ? {} : super(member_keys)
|
|
81
|
+
{ type: type }.merge(base)
|
|
82
|
+
else
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Prepend Message in all message types (prepend needed to override Data#deconstruct_keys)
|
|
89
|
+
MESSAGE_TYPES.each { |klass| klass.prepend(Message) }
|
|
90
|
+
|
|
91
|
+
# Prepend Message in all content block types
|
|
92
|
+
CONTENT_BLOCK_TYPES.each { |klass| klass.prepend(Message) }
|
|
93
|
+
end
|
|
@@ -24,6 +24,43 @@ module ClaudeAgent
|
|
|
24
24
|
def event_type
|
|
25
25
|
event[:type]
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Text delta content, or nil if this is not a text_delta event.
|
|
29
|
+
# @return [String, nil]
|
|
30
|
+
def delta_text
|
|
31
|
+
return nil unless event_type == "content_block_delta"
|
|
32
|
+
delta = event[:delta] || event["delta"]
|
|
33
|
+
return nil unless delta
|
|
34
|
+
type = delta[:type] || delta["type"]
|
|
35
|
+
return nil unless type == "text_delta"
|
|
36
|
+
delta[:text] || delta["text"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The delta type (e.g. "text_delta", "thinking_delta", "input_json_delta").
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def delta_type
|
|
42
|
+
return nil unless event_type == "content_block_delta"
|
|
43
|
+
delta = event[:delta] || event["delta"]
|
|
44
|
+
return nil unless delta
|
|
45
|
+
delta[:type] || delta["type"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Thinking delta text, or nil if this is not a thinking_delta event.
|
|
49
|
+
# @return [String, nil]
|
|
50
|
+
def thinking_text
|
|
51
|
+
return nil unless event_type == "content_block_delta"
|
|
52
|
+
delta = event[:delta] || event["delta"]
|
|
53
|
+
return nil unless delta
|
|
54
|
+
type = delta[:type] || delta["type"]
|
|
55
|
+
return nil unless type == "thinking_delta"
|
|
56
|
+
delta[:thinking] || delta["thinking"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Content block index within the message.
|
|
60
|
+
# @return [Integer, nil]
|
|
61
|
+
def content_index
|
|
62
|
+
event[:index] || event["index"]
|
|
63
|
+
end
|
|
27
64
|
end
|
|
28
65
|
|
|
29
66
|
# Rate limit event (TypeScript SDK v0.2.45 parity)
|
data/lib/claude_agent/options.rb
CHANGED
|
@@ -122,10 +122,20 @@ module ClaudeAgent
|
|
|
122
122
|
"Must set allow_dangerously_skip_permissions: true to use bypassPermissions mode"
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
# Auto-compile PermissionPolicy to can_use_tool lambda
|
|
126
|
+
if can_use_tool.is_a?(PermissionPolicy)
|
|
127
|
+
@can_use_tool = can_use_tool.to_can_use_tool
|
|
128
|
+
end
|
|
129
|
+
|
|
125
130
|
if can_use_tool && !can_use_tool.respond_to?(:call)
|
|
126
131
|
raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
|
|
127
132
|
end
|
|
128
133
|
|
|
134
|
+
# Auto-compile HookRegistry to hooks hash
|
|
135
|
+
if hooks.is_a?(HookRegistry)
|
|
136
|
+
@hooks = hooks.to_hooks_hash
|
|
137
|
+
end
|
|
138
|
+
|
|
129
139
|
if on_elicitation && !on_elicitation.respond_to?(:call)
|
|
130
140
|
raise ConfigurationError, "on_elicitation must be callable (Proc, Lambda, or object responding to #call)"
|
|
131
141
|
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Declarative permission policy builder.
|
|
5
|
+
#
|
|
6
|
+
# Compiles allow/deny rules into a `can_use_tool` lambda for use
|
|
7
|
+
# with Options and Conversation. Rules are evaluated in order;
|
|
8
|
+
# first match wins.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
12
|
+
# p.allow "Read", "Grep", "Glob"
|
|
13
|
+
# p.deny "Bash", message: "Bash not allowed"
|
|
14
|
+
# p.deny_all
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example With regex matching
|
|
18
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
19
|
+
# p.allow_matching(/^mcp__/)
|
|
20
|
+
# p.deny_all
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With custom handler for unmatched tools
|
|
24
|
+
# policy = ClaudeAgent::PermissionPolicy.new do |p|
|
|
25
|
+
# p.allow "Read"
|
|
26
|
+
# p.ask { |name, input, ctx| PermissionResultAllow.new }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Module-level convenience
|
|
30
|
+
# ClaudeAgent.permissions do |p|
|
|
31
|
+
# p.allow "Read", "Grep"
|
|
32
|
+
# p.deny "Bash"
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
class PermissionPolicy
|
|
36
|
+
# @param block [Proc] DSL block yielding self
|
|
37
|
+
def initialize(&block)
|
|
38
|
+
@rules = []
|
|
39
|
+
@fallback = nil
|
|
40
|
+
yield self if block_given?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Allow specific tools by exact name.
|
|
44
|
+
#
|
|
45
|
+
# @param tool_names [Array<String>] Tool names to allow
|
|
46
|
+
# @return [self]
|
|
47
|
+
def allow(*tool_names)
|
|
48
|
+
tool_names.flatten.each do |name|
|
|
49
|
+
@rules << { type: :exact, name: name.to_s, action: :allow }
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Deny specific tools by exact name.
|
|
55
|
+
#
|
|
56
|
+
# @param tool_names [Array<String>] Tool names to deny
|
|
57
|
+
# @param message [String] Denial message
|
|
58
|
+
# @param interrupt [Boolean] Whether to interrupt the conversation
|
|
59
|
+
# @return [self]
|
|
60
|
+
def deny(*tool_names, message: "", interrupt: false)
|
|
61
|
+
tool_names.flatten.each do |name|
|
|
62
|
+
@rules << { type: :exact, name: name.to_s, action: :deny, message: message, interrupt: interrupt }
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Allow tools matching a pattern.
|
|
68
|
+
#
|
|
69
|
+
# @param pattern [Regexp, String] Pattern to match against tool names
|
|
70
|
+
# @return [self]
|
|
71
|
+
def allow_matching(pattern)
|
|
72
|
+
@rules << { type: :pattern, pattern: to_regexp(pattern), action: :allow }
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deny tools matching a pattern.
|
|
77
|
+
#
|
|
78
|
+
# @param pattern [Regexp, String] Pattern to match against tool names
|
|
79
|
+
# @param message [String] Denial message
|
|
80
|
+
# @param interrupt [Boolean] Whether to interrupt the conversation
|
|
81
|
+
# @return [self]
|
|
82
|
+
def deny_matching(pattern, message: "", interrupt: false)
|
|
83
|
+
@rules << { type: :pattern, pattern: to_regexp(pattern), action: :deny, message: message, interrupt: interrupt }
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Set a custom handler for tools that don't match any rule.
|
|
88
|
+
#
|
|
89
|
+
# @yield [name, input, context] Called for unmatched tools
|
|
90
|
+
# @return [self]
|
|
91
|
+
def ask(&handler)
|
|
92
|
+
@fallback = handler
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Set fallback to allow all unmatched tools.
|
|
97
|
+
# @return [self]
|
|
98
|
+
def allow_all
|
|
99
|
+
@fallback = :allow
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Set fallback to deny all unmatched tools.
|
|
104
|
+
#
|
|
105
|
+
# @param message [String] Denial message for unmatched tools
|
|
106
|
+
# @return [self]
|
|
107
|
+
def deny_all(message: "Denied by policy")
|
|
108
|
+
@fallback = { action: :deny, message: message }
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Compile this policy into a `can_use_tool` lambda.
|
|
113
|
+
#
|
|
114
|
+
# @return [Proc] Lambda compatible with Options#can_use_tool
|
|
115
|
+
def to_can_use_tool
|
|
116
|
+
rules = @rules.dup.freeze
|
|
117
|
+
fallback = @fallback
|
|
118
|
+
|
|
119
|
+
->(tool_name, tool_input, context) {
|
|
120
|
+
# Check rules in order, first match wins
|
|
121
|
+
rules.each do |rule|
|
|
122
|
+
next unless matches?(rule, tool_name)
|
|
123
|
+
|
|
124
|
+
case rule[:action]
|
|
125
|
+
when :allow
|
|
126
|
+
return PermissionResultAllow.new
|
|
127
|
+
when :deny
|
|
128
|
+
return PermissionResultDeny.new(message: rule[:message], interrupt: rule[:interrupt])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# No rule matched, use fallback
|
|
133
|
+
case fallback
|
|
134
|
+
when :allow
|
|
135
|
+
PermissionResultAllow.new
|
|
136
|
+
when Hash
|
|
137
|
+
PermissionResultDeny.new(message: fallback[:message])
|
|
138
|
+
when Proc
|
|
139
|
+
fallback.call(tool_name, tool_input, context)
|
|
140
|
+
else
|
|
141
|
+
# Default: allow (no policy restriction)
|
|
142
|
+
PermissionResultAllow.new
|
|
143
|
+
end
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether any rules have been defined.
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def empty?
|
|
150
|
+
@rules.empty? && @fallback.nil?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def matches?(rule, tool_name)
|
|
156
|
+
case rule[:type]
|
|
157
|
+
when :exact
|
|
158
|
+
rule[:name] == tool_name.to_s
|
|
159
|
+
when :pattern
|
|
160
|
+
rule[:pattern].match?(tool_name.to_s)
|
|
161
|
+
else
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def to_regexp(pattern)
|
|
167
|
+
case pattern
|
|
168
|
+
when Regexp then pattern
|
|
169
|
+
when String then Regexp.new(pattern)
|
|
170
|
+
else raise ArgumentError, "Pattern must be a Regexp or String, got #{pattern.class}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -132,6 +132,23 @@ module ClaudeAgent
|
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Human-readable label for the permission request.
|
|
136
|
+
#
|
|
137
|
+
# Delegates to {ToolUseBlock#display_label} formatting.
|
|
138
|
+
#
|
|
139
|
+
# @return [String]
|
|
140
|
+
def display_label
|
|
141
|
+
ToolUseBlock.new(id: "", name: tool_name, input: input || {}).display_label
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Detailed summary of the tool call.
|
|
145
|
+
#
|
|
146
|
+
# @param max [Integer] Maximum length before truncation
|
|
147
|
+
# @return [String]
|
|
148
|
+
def summary(max: 60)
|
|
149
|
+
ToolUseBlock.new(id: "", name: tool_name, input: input || {}).summary(max: max)
|
|
150
|
+
end
|
|
151
|
+
|
|
135
152
|
def inspect
|
|
136
153
|
status = resolved? ? "resolved(#{@result&.behavior})" : "pending"
|
|
137
154
|
"#<#{self.class} tool=#{tool_name} status=#{status} age=#{(Time.now - created_at).round(1)}s>"
|
data/lib/claude_agent/session.rb
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ClaudeAgent
|
|
4
|
-
# Historical session finder with Rails-like API.
|
|
4
|
+
# Historical session finder with Rails-like API and Stripe-style resource methods.
|
|
5
5
|
#
|
|
6
|
-
# Wraps SessionInfo with a rich interface for finding sessions
|
|
7
|
-
#
|
|
6
|
+
# Wraps SessionInfo with a rich interface for finding sessions,
|
|
7
|
+
# querying their message transcripts, and mutating session metadata.
|
|
8
8
|
#
|
|
9
9
|
# @example Find a session by ID
|
|
10
10
|
# session = ClaudeAgent::Session.find("abc-123")
|
|
11
11
|
# session.summary # => "Fix login bug"
|
|
12
12
|
#
|
|
13
|
-
# @example
|
|
14
|
-
#
|
|
13
|
+
# @example Retrieve (raises on not found)
|
|
14
|
+
# session = ClaudeAgent::Session.retrieve("abc-123")
|
|
15
15
|
#
|
|
16
|
-
# @example
|
|
17
|
-
# session.
|
|
16
|
+
# @example Mutations
|
|
17
|
+
# session.rename("My Session")
|
|
18
|
+
# session.tag("important")
|
|
19
|
+
# forked = session.fork(title: "Branch off")
|
|
20
|
+
#
|
|
21
|
+
# @example Resume a conversation
|
|
22
|
+
# session.resume(model: "opus") { |c| c.say("Continue") }
|
|
18
23
|
#
|
|
19
24
|
class Session
|
|
20
25
|
attr_reader :session_id, :summary, :last_modified, :file_size,
|
|
21
|
-
:custom_title, :first_prompt, :git_branch, :cwd
|
|
26
|
+
:custom_title, :first_prompt, :git_branch, :cwd,
|
|
27
|
+
:tag, :created_at
|
|
22
28
|
|
|
23
29
|
def initialize(session_info)
|
|
24
30
|
@session_id = session_info.session_id
|
|
@@ -29,6 +35,8 @@ module ClaudeAgent
|
|
|
29
35
|
@first_prompt = session_info.first_prompt
|
|
30
36
|
@git_branch = session_info.git_branch
|
|
31
37
|
@cwd = session_info.cwd
|
|
38
|
+
@tag = session_info.tag
|
|
39
|
+
@created_at = session_info.created_at
|
|
32
40
|
@dir = session_info.cwd
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -39,18 +47,99 @@ module ClaudeAgent
|
|
|
39
47
|
SessionMessageRelation.new(session_id, dir: @dir)
|
|
40
48
|
end
|
|
41
49
|
|
|
50
|
+
# --- Instance Mutation Methods ---
|
|
51
|
+
|
|
52
|
+
# Rename this session.
|
|
53
|
+
#
|
|
54
|
+
# @param title [String] New title
|
|
55
|
+
# @return [self]
|
|
56
|
+
def rename(title)
|
|
57
|
+
SessionMutations.rename_session(session_id, title, dir: @dir)
|
|
58
|
+
@custom_title = title
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Tag this session.
|
|
63
|
+
#
|
|
64
|
+
# @param value [String, nil] Tag value. Pass nil to clear.
|
|
65
|
+
# @return [self]
|
|
66
|
+
def tag_session(value)
|
|
67
|
+
SessionMutations.tag_session(session_id, value, dir: @dir)
|
|
68
|
+
@tag = value
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Fork this session.
|
|
73
|
+
#
|
|
74
|
+
# @param up_to [String, nil] Truncate at this message UUID (inclusive)
|
|
75
|
+
# @param title [String, nil] Title for the forked session
|
|
76
|
+
# @return [Session] The new forked session
|
|
77
|
+
def fork(up_to: nil, title: nil)
|
|
78
|
+
result = ForkSession.call(session_id, up_to_message_id: up_to, title: title, dir: @dir)
|
|
79
|
+
info = GetSessionInfo.call(result.session_id, dir: @dir)
|
|
80
|
+
info ? Session.new(info) : Session.new(SessionInfo.new(
|
|
81
|
+
session_id: result.session_id,
|
|
82
|
+
summary: title || @summary,
|
|
83
|
+
last_modified: Time.now.to_i * 1000,
|
|
84
|
+
file_size: 0
|
|
85
|
+
))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Re-read session metadata from disk.
|
|
89
|
+
#
|
|
90
|
+
# @return [self]
|
|
91
|
+
def reload
|
|
92
|
+
info = GetSessionInfo.call(session_id, dir: @dir)
|
|
93
|
+
raise NotFoundError, "Session not found: #{session_id}" unless info
|
|
94
|
+
|
|
95
|
+
@summary = info.summary
|
|
96
|
+
@last_modified = info.last_modified
|
|
97
|
+
@file_size = info.file_size
|
|
98
|
+
@custom_title = info.custom_title
|
|
99
|
+
@first_prompt = info.first_prompt
|
|
100
|
+
@git_branch = info.git_branch
|
|
101
|
+
@cwd = info.cwd
|
|
102
|
+
@tag = info.tag
|
|
103
|
+
@created_at = info.created_at
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resume this session as a Conversation.
|
|
108
|
+
#
|
|
109
|
+
# @param kwargs Options/Conversation keyword arguments
|
|
110
|
+
# @yield [Conversation] Block form with auto-cleanup
|
|
111
|
+
# @return [Conversation, Object]
|
|
112
|
+
def resume(**kwargs, &block)
|
|
113
|
+
if block
|
|
114
|
+
Conversation.open(resume: session_id, **kwargs, &block)
|
|
115
|
+
else
|
|
116
|
+
Conversation.resume(session_id, **kwargs)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
42
120
|
class << self
|
|
43
|
-
# Find a session by its UUID.
|
|
121
|
+
# Find a session by its UUID (targeted lookup, not full scan).
|
|
44
122
|
#
|
|
45
123
|
# @param session_id [String] UUID of the session
|
|
46
124
|
# @param dir [String, nil] Directory to scope the search
|
|
47
125
|
# @return [Session, nil] The session, or nil if not found
|
|
48
126
|
def find(session_id, dir: nil)
|
|
49
|
-
|
|
50
|
-
info = sessions.find { |s| s.session_id == session_id }
|
|
127
|
+
info = GetSessionInfo.call(session_id, dir: dir)
|
|
51
128
|
info ? new(info) : nil
|
|
52
129
|
end
|
|
53
130
|
|
|
131
|
+
# Retrieve a session by UUID. Raises if not found (Stripe convention).
|
|
132
|
+
#
|
|
133
|
+
# @param session_id [String] UUID of the session
|
|
134
|
+
# @param dir [String, nil] Directory to scope the search
|
|
135
|
+
# @return [Session]
|
|
136
|
+
# @raise [NotFoundError] If session is not found
|
|
137
|
+
def retrieve(session_id, dir: nil)
|
|
138
|
+
info = GetSessionInfo.call(session_id, dir: dir)
|
|
139
|
+
raise NotFoundError, "Session not found: #{session_id}" unless info
|
|
140
|
+
new(info)
|
|
141
|
+
end
|
|
142
|
+
|
|
54
143
|
# List all sessions.
|
|
55
144
|
#
|
|
56
145
|
# @return [Array<Session>]
|
|
@@ -93,9 +93,12 @@ module ClaudeAgent
|
|
|
93
93
|
# @param path [String]
|
|
94
94
|
# @return [String]
|
|
95
95
|
def realpath(path)
|
|
96
|
-
File.realpath(path)
|
|
96
|
+
resolved = File.realpath(path)
|
|
97
|
+
resolved = resolved.encode("UTF-8") unless resolved.encoding == Encoding::UTF_8
|
|
98
|
+
resolved.unicode_normalize(:nfc)
|
|
97
99
|
rescue SystemCallError
|
|
98
|
-
path.
|
|
100
|
+
safe = path.encode("UTF-8") rescue path
|
|
101
|
+
safe.unicode_normalize(:nfc) rescue safe
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
# Get git worktree paths for a directory.
|
|
@@ -35,6 +35,7 @@ module ClaudeAgent
|
|
|
35
35
|
def initialize
|
|
36
36
|
@messages = []
|
|
37
37
|
@result = nil
|
|
38
|
+
@streamed_text = +""
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Append a message to this turn
|
|
@@ -44,6 +45,13 @@ module ClaudeAgent
|
|
|
44
45
|
def <<(message)
|
|
45
46
|
@messages << message
|
|
46
47
|
@result = message if message.is_a?(ResultMessage)
|
|
48
|
+
|
|
49
|
+
# Accumulate streaming text deltas for reliable text access on abort
|
|
50
|
+
if message.is_a?(StreamEvent)
|
|
51
|
+
delta = message.delta_text
|
|
52
|
+
@streamed_text << delta if delta
|
|
53
|
+
end
|
|
54
|
+
|
|
47
55
|
self
|
|
48
56
|
end
|
|
49
57
|
|
|
@@ -61,10 +69,20 @@ module ClaudeAgent
|
|
|
61
69
|
|
|
62
70
|
# --- Text & Thinking ---
|
|
63
71
|
|
|
64
|
-
# All text content
|
|
72
|
+
# All text content from the turn.
|
|
73
|
+
#
|
|
74
|
+
# Returns text from AssistantMessages when available (canonical source).
|
|
75
|
+
# Falls back to accumulated streaming deltas when assistant messages
|
|
76
|
+
# have no text (e.g., turn was aborted before AssistantMessage arrived).
|
|
77
|
+
#
|
|
65
78
|
# @return [String]
|
|
66
79
|
def text
|
|
67
|
-
assistant_messages.map(&:text).join
|
|
80
|
+
t = assistant_messages.map(&:text).join
|
|
81
|
+
if t.empty? && !@streamed_text.empty?
|
|
82
|
+
@streamed_text.dup.freeze
|
|
83
|
+
else
|
|
84
|
+
t
|
|
85
|
+
end
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
# All thinking content concatenated across assistant messages
|
|
@@ -47,4 +47,12 @@ module ClaudeAgent
|
|
|
47
47
|
super
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
# Result of forking a session (TypeScript SDK v0.2.76 parity)
|
|
52
|
+
#
|
|
53
|
+
# @example
|
|
54
|
+
# result = ClaudeAgent.fork_session("abc-123")
|
|
55
|
+
# puts result.session_id # => new UUID
|
|
56
|
+
#
|
|
57
|
+
ForkSessionResult = Data.define(:session_id)
|
|
50
58
|
end
|
data/lib/claude_agent/version.rb
CHANGED