claude_agent 0.7.8 → 0.7.10

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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module ClaudeAgent
6
+ # Shared path/directory infrastructure for session discovery.
7
+ #
8
+ # Provides methods to locate Claude Code session files on disk,
9
+ # encode project directory paths, and resolve git worktrees.
10
+ # Used by both ListSessions and GetSessionMessages.
11
+ #
12
+ module SessionPaths
13
+ MAX_SLUG_LENGTH = 200
14
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
15
+
16
+ module_function
17
+
18
+ # @return [String] Claude config directory
19
+ def config_dir
20
+ (ENV["CLAUDE_CONFIG_DIR"] || File.join(Dir.home, ".claude"))
21
+ end
22
+
23
+ # @return [String] Projects directory within config
24
+ def projects_dir
25
+ File.join(config_dir, "projects")
26
+ end
27
+
28
+ # Get the expected project directory path for a given working directory.
29
+ #
30
+ # @param path [String] Working directory
31
+ # @return [String] Full path to project sessions directory
32
+ def project_dir_for(path)
33
+ File.join(projects_dir, encode_project_dir(path))
34
+ end
35
+
36
+ # Encode a project directory path to a slug for the projects directory.
37
+ # Matches the TypeScript SDK's q9 function exactly.
38
+ #
39
+ # @param path [String] Absolute directory path
40
+ # @return [String] Encoded slug
41
+ def encode_project_dir(path)
42
+ slug = path.gsub(/[^a-zA-Z0-9]/, "-")
43
+ return slug if slug.length <= MAX_SLUG_LENGTH
44
+
45
+ hash = java_string_hash(path)
46
+ "#{slug[0, MAX_SLUG_LENGTH]}-#{hash}"
47
+ end
48
+
49
+ # Java-style string hash (matches TypeScript DM function).
50
+ # Computes hash = ((hash << 5) - hash + charCode) as 32-bit signed integer,
51
+ # then returns absolute value in base 36.
52
+ #
53
+ # @param str [String]
54
+ # @return [String] Base-36 hash
55
+ def java_string_hash(str)
56
+ hash = 0
57
+ str.each_char do |c|
58
+ hash = ((hash << 5) - hash + c.ord) & 0xFFFFFFFF
59
+ # Convert to signed 32-bit integer
60
+ hash -= 0x100000000 if hash >= 0x80000000
61
+ end
62
+ hash.abs.to_s(36)
63
+ end
64
+
65
+ # Look up the project directory for a given path, handling hash suffix fallback.
66
+ # Matches TypeScript's tQ function.
67
+ #
68
+ # @param path [String] Working directory
69
+ # @return [String, nil] Project directory path or nil
70
+ def find_project_dir(path)
71
+ expected = project_dir_for(path)
72
+ return expected if File.directory?(expected)
73
+
74
+ # Try prefix matching for hash-suffixed directories
75
+ slug = encode_project_dir(path)
76
+ return nil if slug.length <= MAX_SLUG_LENGTH
77
+
78
+ prefix = slug[0, MAX_SLUG_LENGTH]
79
+ base = projects_dir
80
+ return nil unless File.directory?(base)
81
+
82
+ Dir.entries(base).each do |entry|
83
+ next if entry.start_with?(".")
84
+ next unless File.directory?(File.join(base, entry))
85
+ return File.join(base, entry) if entry.start_with?("#{prefix}-")
86
+ end
87
+
88
+ nil
89
+ end
90
+
91
+ # Resolve symlinks and normalize a path.
92
+ #
93
+ # @param path [String]
94
+ # @return [String]
95
+ def realpath(path)
96
+ File.realpath(path).unicode_normalize(:nfc)
97
+ rescue SystemCallError
98
+ path.unicode_normalize(:nfc)
99
+ end
100
+
101
+ # Get git worktree paths for a directory.
102
+ #
103
+ # @param dir [String] Working directory
104
+ # @return [Array<String>] Worktree paths
105
+ def git_worktrees(dir)
106
+ output = nil
107
+ IO.popen([ "git", "worktree", "list", "--porcelain" ], chdir: dir, err: File::NULL) do |io|
108
+ Timeout.timeout(5) { output = io.read }
109
+ end
110
+
111
+ return [] unless output
112
+
113
+ output.lines
114
+ .select { |line| line.start_with?("worktree ") }
115
+ .map { |line| line[9..].strip.unicode_normalize(:nfc) }
116
+ rescue SystemCallError, Timeout::Error, Errno::ENOENT
117
+ []
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # A single tool execution in the conversation timeline.
5
+ #
6
+ # Pairs a ToolUseBlock with its ToolResultBlock and adds context
7
+ # about which turn it occurred in and when.
8
+ #
9
+ # @example
10
+ # conversation.tool_activity.each do |activity|
11
+ # puts activity.display_label
12
+ # puts " Turn: #{activity.turn_index}"
13
+ # puts " Duration: #{activity.duration}s" if activity.duration
14
+ # puts " Error!" if activity.error?
15
+ # end
16
+ #
17
+ ToolActivity = Data.define(
18
+ :tool_use,
19
+ :tool_result,
20
+ :turn_index,
21
+ :started_at,
22
+ :completed_at
23
+ ) do
24
+ def initialize(tool_use:, tool_result: nil, turn_index:, started_at: nil, completed_at: nil)
25
+ super
26
+ end
27
+
28
+ # Tool name
29
+ # @return [String]
30
+ def name
31
+ tool_use.name
32
+ end
33
+
34
+ # Human-readable label (delegates to ToolUseBlock#display_label)
35
+ # @return [String]
36
+ def display_label
37
+ tool_use.display_label
38
+ end
39
+
40
+ # Detailed summary (delegates to ToolUseBlock#summary)
41
+ # @param max [Integer] Maximum length
42
+ # @return [String]
43
+ def summary(max: 60)
44
+ tool_use.summary(max: max)
45
+ end
46
+
47
+ # File path if this is a file-based tool
48
+ # @return [String, nil]
49
+ def file_path
50
+ tool_use.file_path
51
+ end
52
+
53
+ # Tool use ID
54
+ # @return [String]
55
+ def id
56
+ tool_use.id
57
+ end
58
+
59
+ # Whether the tool produced an error result
60
+ # @return [Boolean]
61
+ def error?
62
+ tool_result&.is_error == true
63
+ end
64
+
65
+ # Whether the tool execution is complete (has a result)
66
+ # @return [Boolean]
67
+ def complete?
68
+ !tool_result.nil?
69
+ end
70
+
71
+ # Duration in seconds (nil if timing not available)
72
+ # @return [Float, nil]
73
+ def duration
74
+ return nil unless started_at && completed_at
75
+ completed_at - started_at
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Represents a complete agent turn — everything from sending a prompt
5
+ # to receiving the final ResultMessage.
6
+ #
7
+ # Accumulates messages as they flow through and provides convenient
8
+ # accessors for text, tool use, usage data, and more. Eliminates the
9
+ # 5+ message type `case` statement that every app rewrites.
10
+ #
11
+ # @example Via Client
12
+ # turn = client.send_and_receive("Fix the bug in auth.rb")
13
+ # puts turn.text
14
+ # puts "Cost: $#{turn.cost}"
15
+ # puts "Tools used: #{turn.tool_uses.map(&:display_label).join(", ")}"
16
+ #
17
+ # @example With streaming block
18
+ # turn = client.send_and_receive("Fix the bug") do |msg|
19
+ # case msg
20
+ # when ClaudeAgent::AssistantMessage
21
+ # print msg.text
22
+ # end
23
+ # end
24
+ # puts "\nDone! #{turn.tool_uses.size} tools used"
25
+ #
26
+ # @example Via one-shot query
27
+ # turn = ClaudeAgent.query_turn(prompt: "What is 2+2?")
28
+ # puts turn.text
29
+ #
30
+ class TurnResult
31
+ # All messages received during this turn
32
+ # @return [Array<Message>]
33
+ attr_reader :messages
34
+
35
+ def initialize
36
+ @messages = []
37
+ @result = nil
38
+ end
39
+
40
+ # Append a message to this turn
41
+ #
42
+ # @param message [Message] Any SDK message
43
+ # @return [self]
44
+ def <<(message)
45
+ @messages << message
46
+ @result = message if message.is_a?(ResultMessage)
47
+ self
48
+ end
49
+
50
+ # The final ResultMessage, or nil if the turn is still in progress
51
+ # @return [ResultMessage, nil]
52
+ def result
53
+ @result
54
+ end
55
+
56
+ # Whether a ResultMessage has been received
57
+ # @return [Boolean]
58
+ def complete?
59
+ !@result.nil?
60
+ end
61
+
62
+ # --- Text & Thinking ---
63
+
64
+ # All text content concatenated across assistant messages
65
+ # @return [String]
66
+ def text
67
+ assistant_messages.map(&:text).join
68
+ end
69
+
70
+ # All thinking content concatenated across assistant messages
71
+ # @return [String]
72
+ def thinking
73
+ assistant_messages.map(&:thinking).join
74
+ end
75
+
76
+ # --- Tool Use ---
77
+
78
+ # All tool use blocks across all assistant messages
79
+ # @return [Array<ToolUseBlock, ServerToolUseBlock>]
80
+ def tool_uses
81
+ assistant_messages.flat_map { |m| m.content.select { |b| b.is_a?(ToolUseBlock) || b.is_a?(ServerToolUseBlock) } }
82
+ end
83
+
84
+ # All tool result blocks from user messages (system-generated tool responses)
85
+ # @return [Array<ToolResultBlock, ServerToolResultBlock>]
86
+ def tool_results
87
+ user_messages
88
+ .select { |m| m.content.is_a?(Array) }
89
+ .flat_map { |m| m.content.select { |b| b.is_a?(ToolResultBlock) || b.is_a?(ServerToolResultBlock) } }
90
+ end
91
+
92
+ # Tool use/result pairs matched by ID
93
+ #
94
+ # Each entry is a Hash with:
95
+ # - `:tool_use` — the ToolUseBlock or ServerToolUseBlock
96
+ # - `:tool_result` — the matching ToolResultBlock/ServerToolResultBlock (nil if not yet received)
97
+ #
98
+ # @return [Array<Hash>]
99
+ def tool_executions
100
+ results_by_id = tool_results.each_with_object({}) { |r, h| h[r.tool_use_id] = r }
101
+ tool_uses.map do |tool_use|
102
+ { tool_use: tool_use, tool_result: results_by_id[tool_use.id] }
103
+ end
104
+ end
105
+
106
+ # --- Result Accessors ---
107
+
108
+ # Token usage from the ResultMessage
109
+ # @return [Hash, nil]
110
+ def usage
111
+ @result&.usage
112
+ end
113
+
114
+ # Total cost in USD
115
+ # @return [Float, nil]
116
+ def cost
117
+ @result&.total_cost_usd
118
+ end
119
+
120
+ # Wall-clock duration in milliseconds
121
+ # @return [Integer, nil]
122
+ def duration_ms
123
+ @result&.duration_ms
124
+ end
125
+
126
+ # API-only duration in milliseconds
127
+ # @return [Integer, nil]
128
+ def duration_api_ms
129
+ @result&.duration_api_ms
130
+ end
131
+
132
+ # Session ID for resumption
133
+ # @return [String, nil]
134
+ def session_id
135
+ @result&.session_id
136
+ end
137
+
138
+ # Number of turns in the session
139
+ # @return [Integer, nil]
140
+ def num_turns
141
+ @result&.num_turns
142
+ end
143
+
144
+ # Why the model stopped generating
145
+ # @return [String, nil]
146
+ def stop_reason
147
+ @result&.stop_reason
148
+ end
149
+
150
+ # Model used for this turn (from first assistant message)
151
+ # @return [String, nil]
152
+ def model
153
+ assistant_messages.first&.model
154
+ end
155
+
156
+ # Per-model usage breakdown
157
+ # @return [Hash, nil]
158
+ def model_usage
159
+ @result&.model_usage
160
+ end
161
+
162
+ # Structured output (if requested via options)
163
+ # @return [Object, nil]
164
+ def structured_output
165
+ @result&.structured_output
166
+ end
167
+
168
+ # Tools that were denied by permission callbacks
169
+ # @return [Array<SDKPermissionDenial>]
170
+ def permission_denials
171
+ @result&.permission_denials || []
172
+ end
173
+
174
+ # Errors from the result
175
+ # @return [Array<String>]
176
+ def errors
177
+ @result&.errors || []
178
+ end
179
+
180
+ # --- Status ---
181
+
182
+ # Whether the turn completed successfully
183
+ # @return [Boolean]
184
+ def success?
185
+ @result&.success? || false
186
+ end
187
+
188
+ # Whether the turn ended with an error
189
+ # @return [Boolean]
190
+ def error?
191
+ @result&.error? || false
192
+ end
193
+
194
+ # The result subtype (e.g. "success", "error_max_turns")
195
+ # @return [String, nil]
196
+ def subtype
197
+ @result&.subtype
198
+ end
199
+
200
+ # --- Filtered Message Access ---
201
+
202
+ # All AssistantMessages in this turn
203
+ # @return [Array<AssistantMessage>]
204
+ def assistant_messages
205
+ @messages.select { |m| m.is_a?(AssistantMessage) }
206
+ end
207
+
208
+ # All UserMessages in this turn (including system-generated tool result messages)
209
+ # @return [Array<UserMessage, UserMessageReplay>]
210
+ def user_messages
211
+ @messages.select { |m| m.is_a?(UserMessage) || m.is_a?(UserMessageReplay) }
212
+ end
213
+
214
+ # All StreamEvents in this turn
215
+ # @return [Array<StreamEvent>]
216
+ def stream_events
217
+ @messages.select { |m| m.is_a?(StreamEvent) }
218
+ end
219
+
220
+ # All content blocks across all assistant messages
221
+ # @return [Array<TextBlock, ThinkingBlock, ToolUseBlock, ...>]
222
+ def content_blocks
223
+ assistant_messages.flat_map(&:content)
224
+ end
225
+
226
+ # --- Inspection ---
227
+
228
+ def inspect
229
+ status = complete? ? (success? ? "success" : "error") : "in_progress"
230
+ parts = [ "#<#{self.class} status=#{status}" ]
231
+ parts << "messages=#{@messages.size}"
232
+ parts << "text=#{text.length}chars" unless text.empty?
233
+ parts << "tools=#{tool_uses.size}" unless tool_uses.empty?
234
+ parts << "cost=$#{cost}" if cost
235
+ parts << "duration=#{duration_ms}ms" if duration_ms
236
+ "#{parts.join(" ")}>"
237
+ end
238
+ end
239
+ end
@@ -184,6 +184,51 @@ module ClaudeAgent
184
184
  # max_turns: 10
185
185
  # )
186
186
  #
187
+ # Session metadata returned by list_sessions (TypeScript SDK parity: SDKSessionInfo)
188
+ #
189
+ # @example
190
+ # session = SessionInfo.new(
191
+ # session_id: "abc-123",
192
+ # summary: "Fix login bug",
193
+ # last_modified: 1706000000000,
194
+ # file_size: 4096,
195
+ # custom_title: "Login fix",
196
+ # first_prompt: "Help me fix the login page",
197
+ # git_branch: "fix/login",
198
+ # cwd: "/Users/dev/myapp"
199
+ # )
200
+ #
201
+ SessionInfo = Data.define(
202
+ :session_id,
203
+ :summary,
204
+ :last_modified,
205
+ :file_size,
206
+ :custom_title,
207
+ :first_prompt,
208
+ :git_branch,
209
+ :cwd
210
+ ) do
211
+ def initialize(session_id:, summary:, last_modified:, file_size:, custom_title: nil, first_prompt: nil, git_branch: nil, cwd: nil)
212
+ super
213
+ end
214
+ end
215
+
216
+ # Message from a session transcript returned by get_session_messages (TypeScript SDK v0.2.59 parity)
217
+ #
218
+ # @example
219
+ # msg = SessionMessage.new(
220
+ # type: "user",
221
+ # uuid: "abc-123",
222
+ # session_id: "def-456",
223
+ # message: { "role" => "user", "content" => [{ "type" => "text", "text" => "Hello" }] }
224
+ # )
225
+ #
226
+ SessionMessage = Data.define(:type, :uuid, :session_id, :message, :parent_tool_use_id) do
227
+ def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil)
228
+ super
229
+ end
230
+ end
231
+
187
232
  AgentDefinition = Data.define(
188
233
  :description,
189
234
  :prompt,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgent
4
- VERSION = "0.7.8"
4
+ VERSION = "0.7.10"
5
5
  end
data/lib/claude_agent.rb CHANGED
@@ -16,15 +16,71 @@ require_relative "claude_agent/messages"
16
16
  require_relative "claude_agent/message_parser"
17
17
  require_relative "claude_agent/hooks"
18
18
  require_relative "claude_agent/permissions"
19
+ require_relative "claude_agent/permission_request"
20
+ require_relative "claude_agent/permission_queue"
19
21
  require_relative "claude_agent/control_protocol"
20
22
  require_relative "claude_agent/transport/base"
21
23
  require_relative "claude_agent/transport/subprocess"
22
24
  require_relative "claude_agent/mcp/tool"
23
25
  require_relative "claude_agent/mcp/server"
26
+ require_relative "claude_agent/cumulative_usage"
27
+ require_relative "claude_agent/event_handler"
28
+ require_relative "claude_agent/turn_result"
29
+ require_relative "claude_agent/tool_activity"
24
30
  require_relative "claude_agent/query"
25
31
  require_relative "claude_agent/client"
26
- require_relative "claude_agent/session" # V2 Session API (unstable)
32
+ require_relative "claude_agent/conversation"
33
+ require_relative "claude_agent/session_paths" # Shared session path infrastructure
34
+ require_relative "claude_agent/list_sessions" # Session discovery (TypeScript SDK v0.2.53 parity)
35
+ require_relative "claude_agent/get_session_messages" # Session transcript reading (TypeScript SDK v0.2.59 parity)
36
+ require_relative "claude_agent/session_message_relation" # Chainable message query object
37
+ require_relative "claude_agent/session" # Session finder + V2 Session API (unstable)
27
38
 
28
39
  module ClaudeAgent
29
- # Re-export key classes at module level for convenience
40
+ class << self
41
+ # Create a new Conversation
42
+ #
43
+ # @see Conversation#initialize
44
+ # @return [Conversation]
45
+ def conversation(**kwargs)
46
+ Conversation.new(**kwargs)
47
+ end
48
+
49
+ # List past sessions with metadata
50
+ #
51
+ # Reads session metadata directly from disk without spawning a CLI subprocess.
52
+ # Returns SessionInfo objects sorted by last modified time (most recent first).
53
+ #
54
+ # @param dir [String, nil] Directory to scope sessions to (includes git worktrees).
55
+ # When nil, returns sessions from all projects.
56
+ # @param limit [Integer, nil] Maximum number of sessions to return.
57
+ # @return [Array<SessionInfo>]
58
+ def list_sessions(dir: nil, limit: nil)
59
+ ListSessions.call(dir: dir, limit: limit)
60
+ end
61
+
62
+ # Read messages from a past session's transcript
63
+ #
64
+ # Reads the session JSONL file from disk, reconstructs the main conversation
65
+ # thread, and returns user/assistant messages with optional pagination.
66
+ #
67
+ # @param session_id [String] UUID of the session to read
68
+ # @param dir [String, nil] Project directory to find the session in.
69
+ # When nil, searches all projects.
70
+ # @param limit [Integer, nil] Maximum number of messages to return.
71
+ # @param offset [Integer, nil] Number of messages to skip from the start.
72
+ # @return [Array<SessionMessage>]
73
+ def get_session_messages(session_id, dir: nil, limit: nil, offset: nil)
74
+ GetSessionMessages.call(session_id, dir: dir, limit: limit, offset: offset)
75
+ end
76
+
77
+ # Resume a previous Conversation by session ID
78
+ #
79
+ # @param session_id [String] Session ID to resume
80
+ # @see Conversation.resume
81
+ # @return [Conversation]
82
+ def resume_conversation(session_id, **kwargs)
83
+ Conversation.resume(session_id, **kwargs)
84
+ end
85
+ end
30
86
  end