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.
@@ -241,7 +241,7 @@ module ClaudeAgent
241
241
  # Get the event type from the raw event
242
242
  # @return [String, nil]
243
243
  def event_type
244
- event["type"]
244
+ event[:type]
245
245
  end
246
246
  end
247
247
 
@@ -267,13 +267,13 @@ module ClaudeAgent
267
267
  # Get the compaction trigger type
268
268
  # @return [String] "manual" or "auto"
269
269
  def trigger
270
- compact_metadata[:trigger] || compact_metadata["trigger"]
270
+ compact_metadata[:trigger]
271
271
  end
272
272
 
273
273
  # Get the token count before compaction
274
274
  # @return [Integer, nil]
275
275
  def pre_tokens
276
- compact_metadata[:pre_tokens] || compact_metadata["pre_tokens"]
276
+ compact_metadata[:pre_tokens]
277
277
  end
278
278
  end
279
279
 
@@ -654,6 +654,41 @@ module ClaudeAgent
654
654
  end
655
655
  end
656
656
 
657
+ # Task progress message (TypeScript SDK v0.2.51 parity)
658
+ #
659
+ # Reports progress during background task (subagent) execution.
660
+ # Contains usage information and description of what the task is doing.
661
+ #
662
+ # @example
663
+ # msg = TaskProgressMessage.new(
664
+ # uuid: "msg-123",
665
+ # session_id: "session-abc",
666
+ # task_id: "task-456",
667
+ # description: "Searching codebase for patterns",
668
+ # usage: { total_tokens: 5000, tool_uses: 3, duration_ms: 2500 }
669
+ # )
670
+ #
671
+ TaskProgressMessage = Data.define(
672
+ :uuid, :session_id, :task_id, :tool_use_id,
673
+ :description, :usage, :last_tool_name
674
+ ) do
675
+ def initialize(
676
+ uuid:,
677
+ session_id:,
678
+ task_id:,
679
+ description:,
680
+ usage: nil,
681
+ tool_use_id: nil,
682
+ last_tool_name: nil
683
+ )
684
+ super
685
+ end
686
+
687
+ def type
688
+ :task_progress
689
+ end
690
+ end
691
+
657
692
  # Rate limit event (TypeScript SDK v0.2.45 parity)
658
693
  #
659
694
  # Reports rate limit status and utilization information.
@@ -663,12 +698,12 @@ module ClaudeAgent
663
698
  # uuid: "msg-123",
664
699
  # session_id: "session-abc",
665
700
  # rate_limit_info: {
666
- # "status" => "allowed_warning",
667
- # "resetsAt" => 1700000000,
668
- # "rateLimitType" => "five_hour",
669
- # "utilization" => 0.85,
670
- # "isUsingOverage" => false,
671
- # "overageStatus" => "available"
701
+ # status: "allowed_warning",
702
+ # resetsAt: 1700000000,
703
+ # rateLimitType: "five_hour",
704
+ # utilization: 0.85,
705
+ # isUsingOverage: false,
706
+ # overageStatus: "available"
672
707
  # }
673
708
  # )
674
709
  # msg.status # => "allowed_warning"
@@ -685,7 +720,7 @@ module ClaudeAgent
685
720
  # Get the rate limit status
686
721
  # @return [String, nil]
687
722
  def status
688
- rate_limit_info["status"] || rate_limit_info[:status]
723
+ rate_limit_info[:status]
689
724
  end
690
725
  end
691
726
 
@@ -719,11 +754,11 @@ module ClaudeAgent
719
754
  # msg = FilesPersistedEvent.new(
720
755
  # uuid: "msg-123",
721
756
  # session_id: "session-abc",
722
- # files: [{ "filename" => "test.rb", "file_id" => "file-456" }],
757
+ # files: [{ filename: "test.rb", file_id: "file-456" }],
723
758
  # failed: [],
724
759
  # processed_at: "2026-01-30T12:00:00Z"
725
760
  # )
726
- # msg.files.first["filename"] # => "test.rb"
761
+ # msg.files.first[:filename] # => "test.rb"
727
762
  #
728
763
  FilesPersistedEvent = Data.define(
729
764
  :uuid,
@@ -747,6 +782,42 @@ module ClaudeAgent
747
782
  end
748
783
  end
749
784
 
785
+ # Generic message for unknown/future protocol types
786
+ #
787
+ # Wraps unrecognized top-level message types so they can be inspected
788
+ # without crashing the application. Supports dynamic field access via
789
+ # `[]` and `method_missing`.
790
+ #
791
+ # @example
792
+ # msg = GenericMessage.new(message_type: "fancy_new", raw: { data: "hello" })
793
+ # msg.type # => :fancy_new
794
+ # msg[:data] # => "hello"
795
+ # msg.data # => "hello"
796
+ # msg.to_h # => { data: "hello" }
797
+ #
798
+ GenericMessage = Data.define(:message_type, :raw) do
799
+ def type
800
+ message_type&.to_sym || :unknown
801
+ end
802
+
803
+ def to_h
804
+ raw
805
+ end
806
+
807
+ def [](key)
808
+ raw[key]
809
+ end
810
+
811
+ def respond_to_missing?(name, include_private = false)
812
+ raw.key?(name) || super
813
+ end
814
+
815
+ def method_missing(name, *args)
816
+ return raw[name] if args.empty? && raw.key?(name)
817
+ super
818
+ end
819
+ end
820
+
750
821
  # All message types
751
822
  MESSAGE_TYPES = [
752
823
  UserMessage,
@@ -766,7 +837,9 @@ module ClaudeAgent
766
837
  ToolUseSummaryMessage,
767
838
  FilesPersistedEvent,
768
839
  TaskStartedMessage,
840
+ TaskProgressMessage,
769
841
  RateLimitEvent,
770
- PromptSuggestionMessage
842
+ PromptSuggestionMessage,
843
+ GenericMessage
771
844
  ].freeze
772
845
  end
@@ -56,6 +56,7 @@ module ClaudeAgent
56
56
  system_prompt append_system_prompt
57
57
  model fallback_model
58
58
  permission_mode permission_prompt_tool_name can_use_tool allow_dangerously_skip_permissions
59
+ permission_queue
59
60
  continue_conversation resume fork_session resume_session_at session_id
60
61
  max_turns max_budget_usd thinking effort max_thinking_tokens
61
62
  strict_mcp_config mcp_servers hooks
@@ -309,10 +310,10 @@ module ClaudeAgent
309
310
  raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
310
311
  end
311
312
 
312
- # Auto-set permission_prompt_tool_name to "stdio" when can_use_tool is configured
313
- # (Python/TypeScript SDK parity) so the CLI routes permission prompts through the
314
- # control protocol instead of interactive terminal prompts
315
- if can_use_tool && !permission_prompt_tool_name
313
+ # Auto-set permission_prompt_tool_name to "stdio" when can_use_tool or
314
+ # permission_queue is configured, so the CLI routes permission prompts
315
+ # through the control protocol instead of interactive terminal prompts
316
+ if (can_use_tool || permission_queue) && !permission_prompt_tool_name
316
317
  @permission_prompt_tool_name = "stdio"
317
318
  end
318
319
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Thread-safe queue of pending permission requests.
5
+ #
6
+ # Provides both blocking and non-blocking access to permission
7
+ # requests that need resolution from the main thread.
8
+ #
9
+ # @example Non-blocking poll in a UI timer
10
+ # while request = client.permission_queue.poll
11
+ # show_dialog(request)
12
+ # end
13
+ #
14
+ # @example Blocking wait
15
+ # request = client.permission_queue.pop # blocks until available
16
+ # request.allow!
17
+ #
18
+ class PermissionQueue
19
+ def initialize
20
+ @queue = Queue.new
21
+ end
22
+
23
+ # Non-blocking poll for the next pending request.
24
+ #
25
+ # @return [PermissionRequest, nil] The next request, or nil if empty
26
+ def poll
27
+ @queue.pop(true)
28
+ rescue ThreadError
29
+ nil
30
+ end
31
+
32
+ # Blocking pop — waits for a request to arrive.
33
+ #
34
+ # @param timeout [Numeric, nil] Maximum seconds to wait (nil = wait forever)
35
+ # @return [PermissionRequest, nil] The request, or nil if timed out
36
+ def pop(timeout: nil)
37
+ if timeout
38
+ deadline = Time.now + timeout
39
+ loop do
40
+ request = poll
41
+ return request if request
42
+ remaining = deadline - Time.now
43
+ return nil if remaining <= 0
44
+ sleep([ remaining, 0.05 ].min)
45
+ end
46
+ else
47
+ @queue.pop
48
+ end
49
+ end
50
+
51
+ # Check if there are pending requests.
52
+ # @return [Boolean]
53
+ def empty?
54
+ @queue.empty?
55
+ end
56
+
57
+ # Number of pending requests.
58
+ # @return [Integer]
59
+ def size
60
+ @queue.size
61
+ end
62
+
63
+ # @api private
64
+ # Push a request onto the queue (called by ControlProtocol).
65
+ #
66
+ # @param request [PermissionRequest]
67
+ # @return [void]
68
+ def push(request)
69
+ @queue.push(request)
70
+ end
71
+
72
+ # Deny all pending requests and drain the queue.
73
+ #
74
+ # Used during abort/disconnect to unblock the reader thread.
75
+ #
76
+ # @param reason [String] Denial reason for all pending requests
77
+ # @return [Array<PermissionRequest>] The drained requests
78
+ def drain!(reason: "Operation aborted")
79
+ requests = []
80
+ while (request = poll)
81
+ request.deny!(message: reason) unless request.resolved?
82
+ requests << request
83
+ end
84
+ requests
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # A deferred permission request that can be resolved from any thread.
5
+ #
6
+ # When the control protocol receives a can_use_tool request and
7
+ # queue-based permissions are enabled, it creates a PermissionRequest
8
+ # and enqueues it. The main/UI thread resolves it by calling
9
+ # {#allow!} or {#deny!}, which unblocks the reader thread.
10
+ #
11
+ # @example Resolving from a UI thread
12
+ # request = client.pending_permission
13
+ # puts "Tool: #{request.tool_name}, Input: #{request.input}"
14
+ # request.allow!
15
+ #
16
+ # @example Denying with a reason
17
+ # request.deny!(message: "Not allowed in production")
18
+ #
19
+ # @example Hybrid mode — defer from a can_use_tool callback
20
+ # can_use_tool: ->(name, input, context) {
21
+ # if name == "Read"
22
+ # ClaudeAgent::PermissionResultAllow.new # auto-allow reads
23
+ # else
24
+ # context.request.defer! # defer writes/bash to the UI
25
+ # end
26
+ # }
27
+ #
28
+ class PermissionRequest
29
+ attr_reader :tool_name, :input, :context, :request_id, :created_at
30
+
31
+ def initialize(tool_name:, input:, context:, request_id:)
32
+ @tool_name = tool_name
33
+ @input = input
34
+ @context = context
35
+ @request_id = request_id
36
+ @created_at = Time.now
37
+
38
+ @mutex = Mutex.new
39
+ @condition = ConditionVariable.new
40
+ @resolved = false
41
+ @deferred = false
42
+ @result = nil
43
+ end
44
+
45
+ # Allow the tool to execute.
46
+ #
47
+ # @param updated_input [Hash, nil] Modified input to send to the tool
48
+ # @param updated_permissions [Array<PermissionUpdate>, nil] Permission rule updates
49
+ # @return [void]
50
+ # @raise [Error] If already resolved
51
+ def allow!(updated_input: nil, updated_permissions: nil)
52
+ resolve!(PermissionResultAllow.new(
53
+ updated_input: updated_input,
54
+ updated_permissions: updated_permissions,
55
+ tool_use_id: context&.tool_use_id
56
+ ))
57
+ end
58
+
59
+ # Deny the tool execution.
60
+ #
61
+ # @param message [String] Reason for denial
62
+ # @param interrupt [Boolean] Whether to interrupt the agent
63
+ # @return [void]
64
+ # @raise [Error] If already resolved
65
+ def deny!(message: "", interrupt: false)
66
+ resolve!(PermissionResultDeny.new(
67
+ message: message,
68
+ interrupt: interrupt,
69
+ tool_use_id: context&.tool_use_id
70
+ ))
71
+ end
72
+
73
+ # Mark this request as deferred (enqueue instead of resolving synchronously).
74
+ #
75
+ # Called from within a can_use_tool callback to signal that the
76
+ # callback will not return an answer. The protocol will enqueue
77
+ # the request and wait for {#allow!} or {#deny!} from another thread.
78
+ #
79
+ # @return [self]
80
+ def defer!
81
+ @mutex.synchronize { @deferred = true }
82
+ self
83
+ end
84
+
85
+ # Check if this request was deferred by a callback.
86
+ # @return [Boolean]
87
+ def deferred?
88
+ @mutex.synchronize { @deferred }
89
+ end
90
+
91
+ # Check if this request has been resolved.
92
+ # @return [Boolean]
93
+ def resolved?
94
+ @mutex.synchronize { @resolved }
95
+ end
96
+
97
+ # Check if this request is still pending.
98
+ # @return [Boolean]
99
+ def pending?
100
+ !resolved?
101
+ end
102
+
103
+ # Get the result (nil if not yet resolved).
104
+ # @return [PermissionResultAllow, PermissionResultDeny, nil]
105
+ def result
106
+ @mutex.synchronize { @result }
107
+ end
108
+
109
+ # @api private
110
+ # Block until resolved (called by ControlProtocol reader thread).
111
+ #
112
+ # @param timeout [Numeric, nil] Timeout in seconds
113
+ # @return [PermissionResultAllow, PermissionResultDeny]
114
+ # @raise [TimeoutError] If timeout expires before resolution
115
+ def wait(timeout: nil)
116
+ @mutex.synchronize do
117
+ unless @resolved
118
+ deadline = timeout ? Time.now + timeout : nil
119
+ until @resolved
120
+ remaining = deadline ? deadline - Time.now : nil
121
+ if remaining && remaining <= 0
122
+ raise TimeoutError.new(
123
+ "Permission request timed out",
124
+ request_id: request_id,
125
+ timeout_seconds: timeout
126
+ )
127
+ end
128
+ @condition.wait(@mutex, remaining ? [ remaining, 0.5 ].min : 0.5)
129
+ end
130
+ end
131
+ @result
132
+ end
133
+ end
134
+
135
+ def inspect
136
+ status = resolved? ? "resolved(#{@result&.behavior})" : "pending"
137
+ "#<#{self.class} tool=#{tool_name} status=#{status} age=#{(Time.now - created_at).round(1)}s>"
138
+ end
139
+
140
+ private
141
+
142
+ def resolve!(result)
143
+ @mutex.synchronize do
144
+ raise Error, "Permission request already resolved" if @resolved
145
+ @result = result
146
+ @resolved = true
147
+ @condition.broadcast
148
+ end
149
+ end
150
+ end
151
+ end
@@ -157,7 +157,8 @@ module ClaudeAgent
157
157
  :tool_use_id,
158
158
  :agent_id,
159
159
  :signal,
160
- :description
160
+ :description,
161
+ :request
161
162
  ) do
162
163
  def initialize(
163
164
  permission_suggestions: nil,
@@ -166,7 +167,8 @@ module ClaudeAgent
166
167
  tool_use_id: nil,
167
168
  agent_id: nil,
168
169
  signal: nil,
169
- description: nil
170
+ description: nil,
171
+ request: nil
170
172
  )
171
173
  super
172
174
  end
@@ -118,6 +118,40 @@ module ClaudeAgent
118
118
  end
119
119
  end
120
120
 
121
+ # One-shot query that returns a TurnResult
122
+ #
123
+ # Like {query}, but accumulates all messages into a {TurnResult}
124
+ # for convenient access to text, tool use, usage, and more.
125
+ #
126
+ # @param prompt [String] The prompt to send to Claude
127
+ # @param options [Options, nil] Configuration options
128
+ # @param transport [Transport::Base, nil] Custom transport
129
+ # @param events [EventHandler, nil] Event handler for dispatching events
130
+ # @yield [Message] Each message as it arrives (optional)
131
+ # @return [TurnResult] The completed turn
132
+ #
133
+ # @example Simple
134
+ # turn = ClaudeAgent.query_turn(prompt: "What is 2+2?")
135
+ # puts turn.text
136
+ # puts "Cost: $#{turn.cost}"
137
+ #
138
+ # @example With events
139
+ # events = ClaudeAgent::EventHandler.new
140
+ # .on_text { |text| print text }
141
+ # .on_result { |r| puts "\nCost: $#{r.total_cost_usd}" }
142
+ # turn = ClaudeAgent.query_turn(prompt: "Explain Ruby", events: events)
143
+ #
144
+ def query_turn(prompt:, options: nil, transport: nil, events: nil)
145
+ turn = TurnResult.new
146
+ query(prompt: prompt, options: options, transport: transport).each do |message|
147
+ turn << message
148
+ events&.handle(message)
149
+ yield message if block_given?
150
+ end
151
+ events&.reset!
152
+ turn
153
+ end
154
+
121
155
  private
122
156
 
123
157
  # Convert an Options object to a hash for merging
@@ -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