claude_agent 0.7.7 → 0.7.9

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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Tracks cumulative usage statistics across multiple conversation turns
5
+ #
6
+ # Token counts are summed across all turns. Cost and turn count reflect
7
+ # the session-cumulative values from the most recent result (the CLI
8
+ # already accumulates these across the session).
9
+ #
10
+ # @example Via Client
11
+ # ClaudeAgent::Client.open do |client|
12
+ # client.send_message("Hello")
13
+ # client.receive_response.each { |m| }
14
+ #
15
+ # client.send_message("Follow up")
16
+ # client.receive_response.each { |m| }
17
+ #
18
+ # usage = client.cumulative_usage
19
+ # puts "Tokens: #{usage.input_tokens} in / #{usage.output_tokens} out"
20
+ # puts "Cost: $#{usage.total_cost_usd}"
21
+ # puts "Turns: #{usage.num_turns}"
22
+ # end
23
+ #
24
+ # @example Standalone
25
+ # tracker = ClaudeAgent::CumulativeUsage.new
26
+ # messages.each { |msg| tracker.track(msg) }
27
+ # puts tracker.to_h
28
+ #
29
+ class CumulativeUsage
30
+ attr_reader :input_tokens, :output_tokens,
31
+ :cache_read_input_tokens, :cache_creation_input_tokens,
32
+ :total_cost_usd, :num_turns,
33
+ :duration_ms, :duration_api_ms
34
+
35
+ def initialize
36
+ @mutex = Mutex.new
37
+ @input_tokens = 0
38
+ @output_tokens = 0
39
+ @cache_read_input_tokens = 0
40
+ @cache_creation_input_tokens = 0
41
+ @total_cost_usd = 0.0
42
+ @num_turns = 0
43
+ @duration_ms = 0
44
+ @duration_api_ms = 0
45
+ end
46
+
47
+ # Update cumulative usage from a message
48
+ #
49
+ # Only processes {ResultMessage} instances; other message types are ignored.
50
+ #
51
+ # @param message [Object] Any message object
52
+ # @return [void]
53
+ def track(message)
54
+ return unless message.is_a?(ResultMessage)
55
+
56
+ @mutex.synchronize do
57
+ if message.usage
58
+ @input_tokens += message.usage[:input_tokens].to_i
59
+ @output_tokens += message.usage[:output_tokens].to_i
60
+ @cache_read_input_tokens += message.usage[:cache_read_input_tokens].to_i
61
+ @cache_creation_input_tokens += message.usage[:cache_creation_input_tokens].to_i
62
+ end
63
+
64
+ # Cost and turn count are session-cumulative from the CLI
65
+ @total_cost_usd = message.total_cost_usd if message.total_cost_usd
66
+ @num_turns = message.num_turns if message.num_turns
67
+
68
+ # Durations are per-turn, sum them
69
+ @duration_ms += message.duration_ms.to_i
70
+ @duration_api_ms += message.duration_api_ms.to_i
71
+ end
72
+ end
73
+
74
+ # Reset all counters to zero
75
+ #
76
+ # @return [void]
77
+ def reset!
78
+ @mutex.synchronize do
79
+ @input_tokens = 0
80
+ @output_tokens = 0
81
+ @cache_read_input_tokens = 0
82
+ @cache_creation_input_tokens = 0
83
+ @total_cost_usd = 0.0
84
+ @num_turns = 0
85
+ @duration_ms = 0
86
+ @duration_api_ms = 0
87
+ end
88
+ end
89
+
90
+ # @return [Hash] All tracked fields as a hash
91
+ def to_h
92
+ @mutex.synchronize do
93
+ {
94
+ input_tokens: @input_tokens,
95
+ output_tokens: @output_tokens,
96
+ cache_read_input_tokens: @cache_read_input_tokens,
97
+ cache_creation_input_tokens: @cache_creation_input_tokens,
98
+ total_cost_usd: @total_cost_usd,
99
+ num_turns: @num_turns,
100
+ duration_ms: @duration_ms,
101
+ duration_api_ms: @duration_api_ms
102
+ }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Dispatches typed events as messages flow through a conversation turn.
5
+ #
6
+ # Register handlers for specific events instead of writing `case` statements
7
+ # over raw message types. Use standalone or via {Client#on}.
8
+ #
9
+ # @example Standalone
10
+ # handler = ClaudeAgent::EventHandler.new
11
+ # handler.on_text { |text| print text }
12
+ # handler.on_tool_use { |tool| puts "Using: #{tool.display_label}" }
13
+ # handler.on_result { |result| puts "Cost: $#{result.total_cost_usd}" }
14
+ #
15
+ # client.receive_response.each { |msg| handler.handle(msg) }
16
+ #
17
+ # @example Via Client
18
+ # client.on_text { |text| print text }
19
+ # client.on_tool_use { |tool| puts tool.display_label }
20
+ # turn = client.send_and_receive("Fix the bug")
21
+ #
22
+ # @example Chaining
23
+ # handler = ClaudeAgent::EventHandler.new
24
+ # .on_text { |text| print text }
25
+ # .on_result { |r| puts "\nDone!" }
26
+ #
27
+ class EventHandler
28
+ # Events:
29
+ # :message — every message (catch-all)
30
+ # :text — AssistantMessage text content
31
+ # :thinking — AssistantMessage thinking content
32
+ # :tool_use — ToolUseBlock or ServerToolUseBlock
33
+ # :tool_result — ToolResultBlock or ServerToolResultBlock, paired with original tool_use
34
+ # :result — ResultMessage (end of turn)
35
+
36
+ def initialize
37
+ @handlers = Hash.new { |h, k| h[k] = [] }
38
+ @pending_tool_uses = {}
39
+ end
40
+
41
+ # Register a handler for an event
42
+ #
43
+ # @param event [Symbol] Event name
44
+ # @yield Event-specific arguments
45
+ # @return [self]
46
+ def on(event, &block)
47
+ @handlers[event] << block
48
+ self
49
+ end
50
+
51
+ # @!method on_message(&block)
52
+ # Register a handler for every message
53
+ # @yield [message] Any message object
54
+ # @return [self]
55
+
56
+ # @!method on_text(&block)
57
+ # Register a handler for assistant text content
58
+ # @yield [String] Text from the AssistantMessage
59
+ # @return [self]
60
+
61
+ # @!method on_thinking(&block)
62
+ # Register a handler for assistant thinking content
63
+ # @yield [String] Thinking from the AssistantMessage
64
+ # @return [self]
65
+
66
+ # @!method on_tool_use(&block)
67
+ # Register a handler for tool use requests
68
+ # @yield [ToolUseBlock, ServerToolUseBlock] The tool use block
69
+ # @return [self]
70
+
71
+ # @!method on_tool_result(&block)
72
+ # Register a handler for tool results, paired with the original request
73
+ # @yield [ToolResultBlock, ToolUseBlock|nil] Result block and matched tool use
74
+ # @return [self]
75
+
76
+ # @!method on_result(&block)
77
+ # Register a handler for the final ResultMessage
78
+ # @yield [ResultMessage] The result
79
+ # @return [self]
80
+
81
+ %i[message text thinking tool_use tool_result result].each do |event|
82
+ define_method(:"on_#{event}") { |&block| on(event, &block) }
83
+ end
84
+
85
+ # Dispatch a message to registered handlers
86
+ #
87
+ # @param message [Message] Any SDK message
88
+ # @return [void]
89
+ def handle(message)
90
+ emit(:message, message)
91
+
92
+ case message
93
+ when AssistantMessage
94
+ handle_assistant(message)
95
+ when UserMessage, UserMessageReplay
96
+ handle_user(message)
97
+ when ResultMessage
98
+ emit(:result, message)
99
+ end
100
+ end
101
+
102
+ # Clear turn-level tracking state (pending tool uses)
103
+ #
104
+ # Called automatically between turns when used via Client.
105
+ # Call manually when reusing a standalone handler across turns.
106
+ #
107
+ # @return [void]
108
+ def reset!
109
+ @pending_tool_uses.clear
110
+ end
111
+
112
+ # Whether any handlers have been registered
113
+ # @return [Boolean]
114
+ def has_handlers?
115
+ @handlers.any? { |_, v| v.any? }
116
+ end
117
+
118
+ private
119
+
120
+ def handle_assistant(message)
121
+ text = message.text
122
+ emit(:text, text) unless text.empty?
123
+
124
+ thinking = message.thinking
125
+ emit(:thinking, thinking) unless thinking.empty?
126
+
127
+ message.content.each do |block|
128
+ case block
129
+ when ToolUseBlock, ServerToolUseBlock
130
+ @pending_tool_uses[block.id] = block
131
+ emit(:tool_use, block)
132
+ end
133
+ end
134
+ end
135
+
136
+ def handle_user(message)
137
+ return unless message.content.is_a?(Array)
138
+
139
+ message.content.each do |block|
140
+ case block
141
+ when ToolResultBlock, ServerToolResultBlock
142
+ tool_use = @pending_tool_uses.delete(block.tool_use_id)
143
+ emit(:tool_result, block, tool_use)
144
+ end
145
+ end
146
+ end
147
+
148
+ def emit(event, *args)
149
+ @handlers[event].each { |handler| handler.call(*args) }
150
+ end
151
+ end
152
+ end
@@ -19,6 +19,8 @@ module ClaudeAgent
19
19
  TeammateIdle
20
20
  TaskCompleted
21
21
  ConfigChange
22
+ WorktreeCreate
23
+ WorktreeRemove
22
24
  ].freeze
23
25
 
24
26
  # Matcher configuration for hooks
@@ -64,6 +66,10 @@ module ClaudeAgent
64
66
 
65
67
  # Base class for hook input types (TypeScript SDK parity)
66
68
  #
69
+ # Subclasses are generated declaratively via {.define_input}, which creates
70
+ # a class with attr_readers, a keyword-argument initializer, and automatic
71
+ # hook_event_name/base field inheritance.
72
+ #
67
73
  class BaseHookInput
68
74
  attr_reader :hook_event_name, :session_id, :transcript_path, :cwd, :permission_mode
69
75
 
@@ -74,255 +80,130 @@ module ClaudeAgent
74
80
  @cwd = cwd
75
81
  @permission_mode = permission_mode
76
82
  end
77
- end
78
-
79
- # Input for PreToolUse hook
80
- #
81
- class PreToolUseInput < BaseHookInput
82
- attr_reader :tool_name, :tool_input, :tool_use_id
83
-
84
- def initialize(tool_name:, tool_input:, tool_use_id: nil, **kwargs)
85
- super(hook_event_name: "PreToolUse", **kwargs)
86
- @tool_name = tool_name
87
- @tool_input = tool_input
88
- @tool_use_id = tool_use_id
89
- end
90
- end
91
-
92
- # Input for PostToolUse hook
93
- #
94
- class PostToolUseInput < BaseHookInput
95
- attr_reader :tool_name, :tool_input, :tool_response, :tool_use_id
96
-
97
- def initialize(tool_name:, tool_input:, tool_response:, tool_use_id: nil, **kwargs)
98
- super(hook_event_name: "PostToolUse", **kwargs)
99
- @tool_name = tool_name
100
- @tool_input = tool_input
101
- @tool_response = tool_response
102
- @tool_use_id = tool_use_id
103
- end
104
- end
105
-
106
- # Input for PostToolUseFailure hook (TypeScript SDK parity)
107
- #
108
- class PostToolUseFailureInput < BaseHookInput
109
- attr_reader :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt
110
-
111
- def initialize(tool_name:, tool_input:, error:, tool_use_id: nil, is_interrupt: nil, **kwargs)
112
- super(hook_event_name: "PostToolUseFailure", **kwargs)
113
- @tool_name = tool_name
114
- @tool_input = tool_input
115
- @tool_use_id = tool_use_id
116
- @error = error
117
- @is_interrupt = is_interrupt
118
- end
119
- end
120
-
121
- # Input for Notification hook (TypeScript SDK parity)
122
- #
123
- class NotificationInput < BaseHookInput
124
- attr_reader :message, :title, :notification_type
125
-
126
- def initialize(message:, title: nil, notification_type: nil, **kwargs)
127
- super(hook_event_name: "Notification", **kwargs)
128
- @message = message
129
- @title = title
130
- @notification_type = notification_type
131
- end
132
- end
133
-
134
- # Input for UserPromptSubmit hook
135
- #
136
- class UserPromptSubmitInput < BaseHookInput
137
- attr_reader :prompt
138
-
139
- def initialize(prompt:, **kwargs)
140
- super(hook_event_name: "UserPromptSubmit", **kwargs)
141
- @prompt = prompt
142
- end
143
- end
144
-
145
- # Input for SessionStart hook (TypeScript SDK parity)
146
- #
147
- class SessionStartInput < BaseHookInput
148
- attr_reader :source, :agent_type, :model
149
-
150
- # @param source [String] One of: "startup", "resume", "clear", "compact"
151
- # @param agent_type [String, nil] Type of agent if running in subagent context
152
- # @param model [String, nil] Model being used for this session
153
- def initialize(source:, agent_type: nil, model: nil, **kwargs)
154
- super(hook_event_name: "SessionStart", **kwargs)
155
- @source = source
156
- @agent_type = agent_type
157
- @model = model
158
- end
159
- end
160
83
 
161
- # Input for SessionEnd hook (TypeScript SDK parity)
162
- #
163
- class SessionEndInput < BaseHookInput
164
- attr_reader :reason
165
-
166
- def initialize(reason:, **kwargs)
167
- super(hook_event_name: "SessionEnd", **kwargs)
168
- @reason = reason
169
- end
170
- end
84
+ # Define a hook input subclass declaratively
85
+ #
86
+ # Generates a complete input class with attr_readers, a keyword-argument
87
+ # initializer, and automatic hook_event_name forwarding. The generated class
88
+ # is registered as ClaudeAgent::{event_name}Input.
89
+ #
90
+ # @param event_name [String] Hook event name (e.g., "PreToolUse")
91
+ # @param required [Array<Symbol>] Required keyword arguments
92
+ # @param optional [Hash<Symbol, Object>] Optional keyword arguments with defaults
93
+ # @param constants [Hash<Symbol, Object>] Constants to define on the class
94
+ # @param block [Proc] Optional block for additional instance methods
95
+ # @return [Class] The generated subclass
96
+ #
97
+ # @example Simple declaration
98
+ # BaseHookInput.define_input "PreToolUse",
99
+ # required: [:tool_name, :tool_input],
100
+ # optional: { tool_use_id: nil }
101
+ #
102
+ # @example With custom behavior
103
+ # BaseHookInput.define_input "Setup",
104
+ # required: [:trigger] do
105
+ # def init? = trigger == "init"
106
+ # def maintenance? = trigger == "maintenance"
107
+ # end
108
+ #
109
+ def self.define_input(event_name, required: [], optional: {}, constants: {}, &block)
110
+ klass = Class.new(self)
111
+ all_fields = required + optional.keys
112
+ klass.attr_reader(*all_fields)
113
+
114
+ # Build keyword argument signature
115
+ params = required.map { |f| "#{f}:" }
116
+ params += optional.map { |f, default| "#{f}: #{default.inspect}" }
117
+ params << "**kwargs"
118
+
119
+ # Build instance variable assignments
120
+ assignments = all_fields.map { |f| "@#{f} = #{f}" }.join("\n ")
121
+
122
+ klass.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
123
+ def initialize(#{params.join(", ")})
124
+ super(hook_event_name: "#{event_name}", **kwargs)
125
+ #{assignments}
126
+ end
127
+ RUBY
171
128
 
172
- # Input for Stop hook
173
- #
174
- class StopInput < BaseHookInput
175
- attr_reader :stop_hook_active
129
+ constants.each { |name, value| klass.const_set(name, value) }
130
+ klass.class_eval(&block) if block
176
131
 
177
- def initialize(stop_hook_active: false, **kwargs)
178
- super(hook_event_name: "Stop", **kwargs)
179
- @stop_hook_active = stop_hook_active
132
+ ClaudeAgent.const_set("#{event_name}Input", klass)
180
133
  end
181
134
  end
182
135
 
183
- # Input for SubagentStart hook (TypeScript SDK parity)
136
+ # --- Hook Input Declarations ---
184
137
  #
185
- class SubagentStartInput < BaseHookInput
186
- attr_reader :agent_id, :agent_type
138
+ # Each declaration generates a complete input class with:
139
+ # - attr_readers for all fields
140
+ # - initialize with required/optional keyword arguments
141
+ # - Automatic hook_event_name and base field inheritance
187
142
 
188
- def initialize(agent_id:, agent_type:, **kwargs)
189
- super(hook_event_name: "SubagentStart", **kwargs)
190
- @agent_id = agent_id
191
- @agent_type = agent_type
192
- end
193
- end
143
+ BaseHookInput.define_input "PreToolUse",
144
+ required: [ :tool_name, :tool_input ],
145
+ optional: { tool_use_id: nil }
194
146
 
195
- # Input for SubagentStop hook
196
- #
197
- class SubagentStopInput < BaseHookInput
198
- attr_reader :stop_hook_active, :agent_id, :agent_transcript_path
147
+ BaseHookInput.define_input "PostToolUse",
148
+ required: [ :tool_name, :tool_input, :tool_response ],
149
+ optional: { tool_use_id: nil }
199
150
 
200
- def initialize(stop_hook_active: false, agent_id: nil, agent_transcript_path: nil, **kwargs)
201
- super(hook_event_name: "SubagentStop", **kwargs)
202
- @stop_hook_active = stop_hook_active
203
- @agent_id = agent_id
204
- @agent_transcript_path = agent_transcript_path
205
- end
206
- end
151
+ BaseHookInput.define_input "PostToolUseFailure",
152
+ required: [ :tool_name, :tool_input, :error ],
153
+ optional: { tool_use_id: nil, is_interrupt: nil }
207
154
 
208
- # Input for PreCompact hook
209
- #
210
- class PreCompactInput < BaseHookInput
211
- attr_reader :trigger, :custom_instructions
155
+ BaseHookInput.define_input "Notification",
156
+ required: [ :message ],
157
+ optional: { title: nil, notification_type: nil }
212
158
 
213
- # @param trigger [String] One of: "manual", "auto"
214
- # @param custom_instructions [String, nil] Custom instructions for compaction
215
- def initialize(trigger:, custom_instructions: nil, **kwargs)
216
- super(hook_event_name: "PreCompact", **kwargs)
217
- @trigger = trigger
218
- @custom_instructions = custom_instructions
219
- end
220
- end
159
+ BaseHookInput.define_input "UserPromptSubmit",
160
+ required: [ :prompt ]
221
161
 
222
- # Input for PermissionRequest hook (TypeScript SDK parity)
223
- #
224
- class PermissionRequestInput < BaseHookInput
225
- attr_reader :tool_name, :tool_input, :permission_suggestions
162
+ BaseHookInput.define_input "SessionStart",
163
+ required: [ :source ],
164
+ optional: { agent_type: nil, model: nil }
226
165
 
227
- def initialize(tool_name:, tool_input:, permission_suggestions: nil, **kwargs)
228
- super(hook_event_name: "PermissionRequest", **kwargs)
229
- @tool_name = tool_name
230
- @tool_input = tool_input
231
- @permission_suggestions = permission_suggestions
232
- end
233
- end
166
+ BaseHookInput.define_input "SessionEnd",
167
+ required: [ :reason ]
234
168
 
235
- # Input for Setup hook (TypeScript SDK parity)
236
- #
237
- # Triggered during initial setup or maintenance operations.
238
- #
239
- # @example
240
- # input = SetupInput.new(trigger: "init", session_id: "abc-123")
241
- # input.trigger # => "init"
242
- # input.init? # => true
243
- #
244
- class SetupInput < BaseHookInput
245
- attr_reader :trigger
169
+ BaseHookInput.define_input "Stop",
170
+ optional: { stop_hook_active: false }
246
171
 
247
- # @param trigger [String] One of: "init", "maintenance"
248
- def initialize(trigger:, **kwargs)
249
- super(hook_event_name: "Setup", **kwargs)
250
- @trigger = trigger
251
- end
172
+ BaseHookInput.define_input "SubagentStart",
173
+ required: [ :agent_id, :agent_type ]
252
174
 
253
- # Check if this is an init trigger
254
- # @return [Boolean]
255
- def init?
256
- trigger == "init"
257
- end
175
+ BaseHookInput.define_input "SubagentStop",
176
+ optional: { stop_hook_active: false, agent_id: nil, agent_transcript_path: nil }
258
177
 
259
- # Check if this is a maintenance trigger
260
- # @return [Boolean]
261
- def maintenance?
262
- trigger == "maintenance"
263
- end
264
- end
178
+ BaseHookInput.define_input "PreCompact",
179
+ required: [ :trigger ],
180
+ optional: { custom_instructions: nil }
265
181
 
266
- # Input for TeammateIdle hook (TypeScript SDK v0.2.33 parity)
267
- #
268
- # Fired when a teammate becomes idle.
269
- #
270
- class TeammateIdleInput < BaseHookInput
271
- attr_reader :teammate_name, :team_name
182
+ BaseHookInput.define_input "PermissionRequest",
183
+ required: [ :tool_name, :tool_input ],
184
+ optional: { permission_suggestions: nil }
272
185
 
273
- # @param teammate_name [String] Name of the idle teammate
274
- # @param team_name [String] Name of the team
275
- def initialize(teammate_name:, team_name:, **kwargs)
276
- super(hook_event_name: "TeammateIdle", **kwargs)
277
- @teammate_name = teammate_name
278
- @team_name = team_name
186
+ BaseHookInput.define_input "Setup",
187
+ required: [ :trigger ] do
188
+ def init? = trigger == "init"
189
+ def maintenance? = trigger == "maintenance"
279
190
  end
280
- end
281
191
 
282
- # Input for ConfigChange hook (TypeScript SDK v0.2.49 parity)
283
- #
284
- # Fired when a configuration file changes.
285
- #
286
- # @example
287
- # input = ConfigChangeInput.new(
288
- # source: "user_settings",
289
- # file_path: "~/.claude/settings.json",
290
- # session_id: "sess-123"
291
- # )
292
- #
293
- class ConfigChangeInput < BaseHookInput
294
- attr_reader :source, :file_path
192
+ BaseHookInput.define_input "TeammateIdle",
193
+ required: [ :teammate_name, :team_name ]
295
194
 
296
- SOURCES = %w[user_settings project_settings local_settings policy_settings skills].freeze
195
+ BaseHookInput.define_input "TaskCompleted",
196
+ required: [ :task_id, :task_subject ],
197
+ optional: { task_description: nil, teammate_name: nil, team_name: nil }
297
198
 
298
- # @param source [String] One of SOURCES
299
- # @param file_path [String, nil] Path to the changed file
300
- def initialize(source:, file_path: nil, **kwargs)
301
- super(hook_event_name: "ConfigChange", **kwargs)
302
- @source = source
303
- @file_path = file_path
304
- end
305
- end
199
+ BaseHookInput.define_input "ConfigChange",
200
+ required: [ :source ],
201
+ optional: { file_path: nil },
202
+ constants: { SOURCES: %w[user_settings project_settings local_settings policy_settings skills].freeze }
306
203
 
307
- # Input for TaskCompleted hook (TypeScript SDK v0.2.33 parity)
308
- #
309
- # Fired when a task completes.
310
- #
311
- class TaskCompletedInput < BaseHookInput
312
- attr_reader :task_id, :task_subject, :task_description, :teammate_name, :team_name
204
+ BaseHookInput.define_input "WorktreeCreate",
205
+ required: [ :name ]
313
206
 
314
- # @param task_id [String] ID of the completed task
315
- # @param task_subject [String] Subject of the completed task
316
- # @param task_description [String, nil] Description of the completed task
317
- # @param teammate_name [String, nil] Name of the teammate that completed the task
318
- # @param team_name [String, nil] Name of the team
319
- def initialize(task_id:, task_subject:, task_description: nil, teammate_name: nil, team_name: nil, **kwargs)
320
- super(hook_event_name: "TaskCompleted", **kwargs)
321
- @task_id = task_id
322
- @task_subject = task_subject
323
- @task_description = task_description
324
- @teammate_name = teammate_name
325
- @team_name = team_name
326
- end
327
- end
207
+ BaseHookInput.define_input "WorktreeRemove",
208
+ required: [ :worktree_path ]
328
209
  end