claude_agent 0.7.3 → 0.7.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2d8740456b8efad6aadef1d23c3979de66aac32836f40d87db7f9d5766505fb
4
- data.tar.gz: aaed4a958504810589552f424094edf52d86732f81780d5525208dab297cb964
3
+ metadata.gz: 3d8b88e03dcf883a968958f700a7dd765fbdae8553934b45e967594ad81f9bba
4
+ data.tar.gz: fd16c0e625ed0df2c548c39378e167af73a21c2fa6d7b61cacc351d9cbde78d2
5
5
  SHA512:
6
- metadata.gz: a4b3f2d325aad7faa519896e90016a9518ad8285a45dd315936712f0e10548cb2fec0ba77efb191b86aeb265a2bdb43ecb9bb49115efe097092a2fe5a0d250cb
7
- data.tar.gz: f98723ebf5568c280ac6ea69b767da4d1065760a9eeee1d3ad990191d3553ddbf8deb3ee6cca36a1cd08fe0c8e82125026ec4ba95c158abe36406b8ec173fead
6
+ metadata.gz: e4c7ba1adad712135d2934405b758324f2477e71b7914769deb07ba07fe4a025ca11f2c04a0ec46e3d2fe6954cf02288097ef914eb62c7874aca229aa4cd9c6c
7
+ data.tar.gz: c608d6c49ffb80c0c0aa96a4dca2f042f280060a73f473e271b1b0d099a368239fedf3194264f82f825fc5d8d76dfa4986c44563929f3a83f09773af96f84654
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.4] - 2026-02-07
11
+
12
+ ### Added
13
+ - Configurable logging via `ClaudeAgent.logger` (module-level) and `Options#logger` (per-query)
14
+ - `NullLogger` default for zero overhead when logging is not configured
15
+ - `ClaudeAgent.debug!` convenience method for quick stderr debug logging
16
+ - Backward-compatible with `CLAUDE_AGENT_DEBUG` env var
17
+ - Log points across transport, control protocol, message parser, MCP server, query, and client
18
+
19
+ ### Fixed
20
+ - `can_use_tool` callback now works without hooks or MCP servers configured
21
+ - `PermissionResultAllow` and `PermissionResultDeny` (`Data.define` types) are now correctly recognized in `handle_can_use_tool` instead of silently falling through to allow
22
+ - `normalize_hook_response` now handles `Data.define` return types from hook callbacks
23
+ - Allow responses without explicit `updated_input` now fall back to the original input (Python SDK parity)
24
+
25
+ ### Changed
26
+ - Always use streaming mode with control protocol initialization (Python/TypeScript SDK parity)
27
+ - Removes fragile conditional gate on hooks/MCP/can_use_tool
28
+ - `send_initialize` handshake is now always sent in streaming mode
29
+
30
+ ### Added
31
+ - Auto-set `permission_prompt_tool_name` to `"stdio"` when `can_use_tool` is configured (Python/TypeScript SDK parity)
32
+
10
33
  ## [0.7.3] - 2026-02-06
11
34
 
12
35
  ### Added
data/README.md CHANGED
@@ -818,6 +818,61 @@ session = ClaudeAgent.unstable_v2_create_session(options)
818
818
  | `RewindFilesResult` | Result of rewind_files (can_rewind, error, files_changed, insertions, deletions) |
819
819
  | `SDKPermissionDenial` | Permission denial info (tool_name, tool_use_id, tool_input) |
820
820
 
821
+ ## Logging
822
+
823
+ The SDK includes optional logging with zero overhead when disabled. All log output is silent by default.
824
+
825
+ ### Quick Debug
826
+
827
+ ```ruby
828
+ # Enable debug logging to stderr
829
+ ClaudeAgent.debug!
830
+
831
+ # Or to a file
832
+ ClaudeAgent.debug!(output: File.open("claude_agent.log", "a"))
833
+ ```
834
+
835
+ ### Custom Logger
836
+
837
+ Set any `Logger`-compatible instance at the module level:
838
+
839
+ ```ruby
840
+ ClaudeAgent.logger = Logger.new($stderr, level: :info)
841
+ ```
842
+
843
+ ### Per-Query Logger
844
+
845
+ Override the module-level logger for a specific query or client:
846
+
847
+ ```ruby
848
+ my_logger = Logger.new("query.log", level: :debug)
849
+
850
+ ClaudeAgent.query(prompt: "Hello", options: ClaudeAgent::Options.new(logger: my_logger))
851
+
852
+ # Or with Client
853
+ client = ClaudeAgent::Client.new(options: ClaudeAgent::Options.new(logger: my_logger))
854
+ ```
855
+
856
+ ### Log Output
857
+
858
+ When enabled, the SDK logs events across transport, protocol, parsing, MCP, and query layers:
859
+
860
+ ```
861
+ [ClaudeAgent] [12:00:00.123] INFO -- transport: Process spawned (pid=12345)
862
+ [ClaudeAgent] [12:00:00.456] DEBUG -- protocol: Sending control request: initialize (req_1_abc)
863
+ [ClaudeAgent] [12:00:01.789] INFO -- protocol: Permission decision for Bash: allow
864
+ [ClaudeAgent] [12:00:02.100] INFO -- query: Query complete (3.45s, cost=$0.012)
865
+ ```
866
+
867
+ ### Log Levels
868
+
869
+ | Level | What's Logged |
870
+ |-------|---------------|
871
+ | ERROR | Control request failures, unknown message types |
872
+ | WARN | Force kills, JSON parse errors during buffering, unknown MCP tools |
873
+ | INFO | Process spawn/close, protocol start/stop, permission decisions, tool calls, query start/completion with timing |
874
+ | DEBUG | Full commands, message types received, control request/response routing, reader thread lifecycle |
875
+
821
876
  ## Environment Variables
822
877
 
823
878
  The SDK sets these automatically:
@@ -825,6 +880,12 @@ The SDK sets these automatically:
825
880
  - `CLAUDE_CODE_ENTRYPOINT=sdk-rb`
826
881
  - `CLAUDE_AGENT_SDK_VERSION=<version>`
827
882
 
883
+ Enable debug logging via environment variable:
884
+
885
+ ```bash
886
+ export CLAUDE_AGENT_DEBUG=1
887
+ ```
888
+
828
889
  Skip version checking (for development):
829
890
 
830
891
  ```bash
data/Rakefile CHANGED
@@ -43,7 +43,7 @@ RuboCop::RakeTask.new
43
43
  namespace :rbs do
44
44
  desc "Validate RBS signatures (syntax + type resolution)"
45
45
  task :validate do
46
- sh "bundle exec rbs -I sig validate"
46
+ sh "bundle exec rbs -r logger -I sig validate"
47
47
  end
48
48
 
49
49
  desc "Parse RBS files (syntax check only, faster)"
data/SPEC.md CHANGED
@@ -7,7 +7,7 @@ This document provides a comprehensive specification of the Claude Agent SDK, co
7
7
  - Python SDK: v0.1.31 from GitHub (commit 4b19642)
8
8
  - Ruby SDK: This repository
9
9
 
10
- **Last Updated:** 2026-02-06
10
+ **Last Updated:** 2026-02-07
11
11
 
12
12
  ---
13
13
 
@@ -37,7 +37,7 @@ Configuration options for SDK queries and clients.
37
37
  | `model` | ✅ | ✅ | ✅ | Claude model identifier |
38
38
  | `fallbackModel` | ✅ | ✅ | ✅ | Fallback if primary fails |
39
39
  | `systemPrompt` | ✅ | ✅ | ✅ | String or preset object |
40
- | `appendSystemPrompt` | ✅ | | ✅ | Append to system prompt (TS SDK has via preset) |
40
+ | `appendSystemPrompt` | ✅ | | ✅ | Append to system prompt (via preset) |
41
41
  | `tools` | ✅ | ✅ | ✅ | Array or preset |
42
42
  | `allowedTools` | ✅ | ✅ | ✅ | Auto-allowed tools |
43
43
  | `disallowedTools` | ✅ | ✅ | ✅ | Blocked tools |
@@ -249,23 +249,23 @@ Event hooks for intercepting and modifying SDK behavior.
249
249
 
250
250
  ### Hook Events
251
251
 
252
- | Event | TypeScript | Python | Ruby | Notes |
253
- |----------------------|:----------:|:------:|:----:|---------------------------|
254
- | `PreToolUse` | ✅ | ✅ | ✅ | Before tool execution |
255
- | `PostToolUse` | ✅ | ✅ | ✅ | After tool execution |
256
- | `PostToolUseFailure` | ✅ | ✅ | ✅ | After tool failure |
257
- | `Notification` | ✅ | ✅ | ✅ | System notifications |
258
- | `UserPromptSubmit` | ✅ | ✅ | ✅ | User message submitted |
259
- | `SessionStart` | ✅ | ❌ | ✅ | Session starts |
260
- | `SessionEnd` | ✅ | ❌ | ✅ | Session ends |
261
- | `Stop` | ✅ | ✅ | ✅ | Agent stops |
262
- | `SubagentStart` | ✅ | ✅ | ✅ | Subagent starts |
263
- | `SubagentStop` | ✅ | ✅ | ✅ | Subagent stops |
264
- | `PreCompact` | ✅ | ✅ | ✅ | Before compaction |
265
- | `PermissionRequest` | ✅ | ✅ | ✅ | Permission requested |
266
- | `Setup` | ✅ | ❌ | ✅ | Initial setup/maintenance |
267
- | `TeammateIdle` | ✅ | ❌ | ✅ | Teammate idle (v0.2.33) |
268
- | `TaskCompleted` | ✅ | ❌ | ✅ | Task completed (v0.2.33) |
252
+ | Event | TypeScript | Python | Ruby | Notes |
253
+ |----------------------|:----------:|:------:|:----:|-----------------------------------|
254
+ | `PreToolUse` | ✅ | ✅ | ✅ | Before tool execution |
255
+ | `PostToolUse` | ✅ | ✅ | ✅ | After tool execution |
256
+ | `PostToolUseFailure` | ✅ | ✅ | ✅ | After tool failure (Py v0.1.26) |
257
+ | `Notification` | ✅ | ✅ | ✅ | System notifications (Py v0.1.29) |
258
+ | `UserPromptSubmit` | ✅ | ✅ | ✅ | User message submitted |
259
+ | `SessionStart` | ✅ | ❌ | ✅ | Session starts |
260
+ | `SessionEnd` | ✅ | ❌ | ✅ | Session ends |
261
+ | `Stop` | ✅ | ✅ | ✅ | Agent stops |
262
+ | `SubagentStart` | ✅ | ✅ | ✅ | Subagent starts (Py v0.1.29) |
263
+ | `SubagentStop` | ✅ | ✅ | ✅ | Subagent stops |
264
+ | `PreCompact` | ✅ | ✅ | ✅ | Before compaction |
265
+ | `PermissionRequest` | ✅ | ✅ | ✅ | Permission requested (Py v0.1.29) |
266
+ | `Setup` | ✅ | ❌ | ✅ | Initial setup/maintenance |
267
+ | `TeammateIdle` | ✅ | ❌ | ✅ | Teammate idle (v0.2.33) |
268
+ | `TaskCompleted` | ✅ | ❌ | ✅ | Task completed (v0.2.33) |
269
269
 
270
270
  ### Hook Input Types
271
271
 
@@ -631,16 +631,16 @@ Public API surface for SDK clients.
631
631
 
632
632
  ### Client Class
633
633
 
634
- | Feature | TypeScript | Python | Ruby | Notes |
635
- |----------------------|:----------:|:-------------------:|:-----------------------:|----------------------------------------------------------------------------------|
636
- | Multi-turn client | ❌ | ✅ `ClaudeSDKClient` | ✅ `ClaudeAgent::Client` | Interactive sessions |
637
- | `connect()` | N/A | ✅ | ✅ | Start session |
638
- | `disconnect()` | N/A | ✅ | ✅ | End session |
639
- | `send_message()` | N/A | ✅ | ✅ | Send user message |
640
- | `receive_response()` | N/A | ✅ | ✅ | Receive until result |
641
- | `stream_input()` | N/A | ❌ | ✅ | Stream input messages |
642
- | `abort!()` | N/A | ❌ | ✅ | Abort operations |
643
- | Control methods | N/A | Partial | ✅ | interrupt, setPermissionMode, setModel, rewindFiles (Python); all methods (Ruby) |
634
+ | Feature | TypeScript | Python | Ruby | Notes |
635
+ |----------------------|:----------:|:-------------------:|:-----------------------:|-------------------------------------------------------------------------------------|
636
+ | Multi-turn client | ❌ | ✅ `ClaudeSDKClient` | ✅ `ClaudeAgent::Client` | Interactive sessions |
637
+ | `connect()` | N/A | ✅ | ✅ | Start session |
638
+ | `disconnect()` | N/A | ✅ | ✅ | End session |
639
+ | `send_message()` | N/A | ✅ | ✅ | Send user message |
640
+ | `receive_response()` | N/A | ✅ | ✅ | Receive until result |
641
+ | `stream_input()` | N/A | ❌ | ✅ | Stream input messages |
642
+ | `abort!()` | N/A | ❌ | ✅ | Abort operations |
643
+ | Control methods | N/A | Partial | ✅ | interrupt, setPermissionMode, setModel, rewindFiles, mcpStatus (Python); all (Ruby) |
644
644
 
645
645
  ### Transport
646
646
 
@@ -670,16 +670,13 @@ Public API surface for SDK clients.
670
670
  - `executable`/`executableArgs` are JS-specific (`node`/`bun`/`deno`)
671
671
 
672
672
  ### Python SDK
673
- - Full source available
674
- - Has `Transport` abstract class and several query control methods
675
- - Query supports: `interrupt()`, `set_permission_mode()`, `set_model()`, `rewind_files()`, `stream_input()`, `close()`, `get_mcp_status()`
676
- - Client supports: `interrupt()`, `set_permission_mode()`, `set_model()`, `rewind_files()`, `get_mcp_status()`, `get_server_info()`
673
+ - Full source available with `Transport` abstract class
674
+ - Partial control protocol: query and client support interrupt, setPermissionMode, setModel, rewindFiles, mcpStatus
677
675
  - Missing hooks: SessionStart, SessionEnd, Setup, TeammateIdle, TaskCompleted
678
676
  - Missing permission modes: `delegate`, `dontAsk`
679
677
  - Missing options: `allowDangerouslySkipPermissions`, `persistSession`, `resumeSessionAt`, `sessionId`, `strictMcpConfig`, `init`/`initOnly`/`maintenance`, `debug`/`debugFile`
680
- - `ToolPermissionContext` missing `blockedPath`, `decisionReason`, `toolUseID`, `agentID`
681
- - Has `additionalContext` in `PreToolUseHookSpecificOutput` (added in v0.1.30)
682
- - Has `create_sdk_mcp_server`, `tool()` helper, and MCP tool annotations (added in v0.1.30/v0.1.31)
678
+ - `ToolPermissionContext` missing `blockedPath`, `decisionReason`, `toolUseID`, `agentID`, `description`
679
+ - Has SDK MCP server support with `tool()` helper and annotations
683
680
 
684
681
  ### Ruby SDK (This Repository)
685
682
  - Full TypeScript SDK v0.2.34 feature parity (v0.2.34 contains no new SDK features beyond a Claude Code version bump)
@@ -72,9 +72,11 @@ module ClaudeAgent
72
72
 
73
73
  ENV["CLAUDE_CODE_ENTRYPOINT"] = "sdk-rb-client"
74
74
 
75
+ logger.info("client") { "Connecting" }
75
76
  @protocol = ControlProtocol.new(transport: @transport, options: @options)
76
77
  @server_info = @protocol.start(streaming: true)
77
78
  @connected = true
79
+ logger.info("client") { "Connected" }
78
80
 
79
81
  send_message(prompt) if prompt
80
82
  end
@@ -85,6 +87,7 @@ module ClaudeAgent
85
87
  def disconnect
86
88
  return unless @connected
87
89
 
90
+ logger.info("client") { "Disconnecting" }
88
91
  @protocol&.stop
89
92
  @protocol = nil
90
93
  @connected = false
@@ -105,6 +108,7 @@ module ClaudeAgent
105
108
  # @return [void]
106
109
  def send_message(content, session_id: "default", uuid: nil)
107
110
  require_connection!
111
+ logger.debug("client") { "Sending message (session=#{session_id})" }
108
112
  @protocol.send_user_message(content, session_id: session_id, uuid: uuid)
109
113
  end
110
114
 
@@ -336,6 +340,10 @@ module ClaudeAgent
336
340
 
337
341
  private
338
342
 
343
+ def logger
344
+ @options.effective_logger
345
+ end
346
+
339
347
  def require_connection!
340
348
  raise CLIConnectionError, "Not connected" unless @connected
341
349
  end
@@ -30,7 +30,7 @@ module ClaudeAgent
30
30
  def initialize(transport:, options: nil)
31
31
  @transport = transport
32
32
  @options = options || Options.new
33
- @parser = MessageParser.new
33
+ @parser = MessageParser.new(logger: @options.effective_logger)
34
34
  @server_info = nil
35
35
 
36
36
  # Control protocol state
@@ -57,15 +57,18 @@ module ClaudeAgent
57
57
  # @param prompt [String, nil] Initial prompt for non-streaming mode
58
58
  # @return [Hash, nil] Server info from initialization
59
59
  def start(streaming: true, prompt: nil)
60
+ logger.info("protocol") { "Starting control protocol (streaming=#{streaming})" }
60
61
  @transport.connect(streaming: streaming, prompt: prompt)
61
62
  @running = true
62
63
 
63
64
  # Start background reader thread
64
65
  @reader_thread = Thread.new { reader_loop }
66
+ logger.debug("protocol") { "Reader thread started" }
65
67
 
66
- # Initialize if we have hooks or SDK MCP servers
67
- if streaming && (options.has_hooks? || options.has_sdk_mcp_servers?)
68
+ # Always send initialize in streaming mode (Python/TypeScript SDK parity)
69
+ if streaming
68
70
  @server_info = send_initialize
71
+ logger.info("protocol") { "Initialize complete" }
69
72
  end
70
73
 
71
74
  @server_info
@@ -74,6 +77,7 @@ module ClaudeAgent
74
77
  # Stop the control protocol
75
78
  # @return [void]
76
79
  def stop
80
+ logger.info("protocol") { "Stopping control protocol" }
77
81
  @running = false
78
82
  @transport.end_input
79
83
  @reader_thread&.join(5)
@@ -143,8 +147,7 @@ module ClaudeAgent
143
147
  # Re-raise abort errors
144
148
  raise
145
149
  rescue => e
146
- # Log parsing errors but continue
147
- warn "[ClaudeAgent] Message parse error: #{e.message}" if ENV["CLAUDE_AGENT_DEBUG"]
150
+ logger.warn("protocol") { "Message parse error: #{e.message}" }
148
151
  end
149
152
  end
150
153
  end
@@ -457,16 +460,9 @@ module ClaudeAgent
457
460
  #
458
461
  def set_mcp_servers(servers)
459
462
  # Convert servers hash to format expected by CLI
460
- servers_config = servers.transform_values do |config|
461
- if config.is_a?(Hash)
462
- # Skip SDK servers (they're handled locally) - only send process-based servers
463
- next nil if config[:type] == "sdk" || config["type"] == "sdk"
464
-
465
- config
466
- else
467
- config
468
- end
469
- end.compact
463
+ servers_config = servers.reject do |_, config|
464
+ config.is_a?(Hash) && (config[:type] == "sdk" || config["type"] == "sdk")
465
+ end
470
466
 
471
467
  response = send_control_request(subtype: "mcp_set_servers", servers: servers_config)
472
468
 
@@ -479,6 +475,10 @@ module ClaudeAgent
479
475
 
480
476
  private
481
477
 
478
+ def logger
479
+ @options.effective_logger
480
+ end
481
+
482
482
  # Background thread that reads messages and routes them
483
483
  def reader_loop
484
484
  @transport.read_messages do |raw|
@@ -491,19 +491,22 @@ module ClaudeAgent
491
491
  break unless @running
492
492
 
493
493
  if raw["type"] == "control_request"
494
+ logger.debug("protocol") { "Control request received: #{raw.dig("request", "subtype")}" }
494
495
  handle_control_request(raw)
495
496
  elsif raw["type"] == "control_response"
497
+ logger.debug("protocol") { "Control response received: #{raw.dig("response", "request_id")}" }
496
498
  handle_control_response(raw)
497
499
  else
498
500
  # SDK message - queue for consumer
501
+ logger.debug("protocol") { "Queued message: #{raw["type"]}" }
499
502
  @message_queue.push(raw)
500
503
  end
501
504
  end
502
505
  rescue IOError, Errno::EPIPE
503
- # Transport closed
506
+ logger.debug("protocol") { "Reader thread exiting: transport closed" }
504
507
  @running = false
505
508
  rescue AbortError
506
- # Abort signal raised
509
+ logger.debug("protocol") { "Reader thread exiting: abort signal" }
507
510
  @running = false
508
511
  end
509
512
 
@@ -572,7 +575,10 @@ module ClaudeAgent
572
575
  # @param request [Hash] Request data
573
576
  # @return [Hash] Response
574
577
  def handle_can_use_tool(request)
575
- return { behavior: "allow" } unless options.can_use_tool
578
+ unless options.can_use_tool
579
+ logger.info("protocol") { "Permission decision for #{request["tool_name"]}: allow (no callback)" }
580
+ return { behavior: "allow" }
581
+ end
576
582
 
577
583
  tool_name = request["tool_name"]
578
584
  input = request["input"] || {}
@@ -587,27 +593,14 @@ module ClaudeAgent
587
593
 
588
594
  result = options.can_use_tool.call(tool_name, input, context)
589
595
 
590
- # Normalize result
591
- if result.is_a?(Hash)
592
- if result[:behavior] == "allow"
593
- response = { behavior: "allow" }
594
- response[:updatedInput] = result[:updated_input] if result[:updated_input]
595
- if result[:updated_permissions]
596
- response[:updatedPermissions] = result[:updated_permissions].map do |p|
597
- p.respond_to?(:to_h) ? p.to_h : p
598
- end
599
- end
600
- response
601
- else
602
- {
603
- behavior: "deny",
604
- message: result[:message] || "",
605
- interrupt: result[:interrupt] || false
606
- }
607
- end
608
- else
609
- { behavior: "allow" }
596
+ normalized = result.to_h
597
+ logger.info("protocol") { "Permission decision for #{tool_name}: #{normalized[:behavior]}" }
598
+
599
+ if normalized[:behavior] == "allow" && !normalized.key?(:updatedInput)
600
+ normalized[:updatedInput] = input
610
601
  end
602
+
603
+ normalized
611
604
  end
612
605
 
613
606
  # Handle hook callback request
@@ -619,7 +612,11 @@ module ClaudeAgent
619
612
  tool_use_id = request["tool_use_id"]
620
613
 
621
614
  callback = @hook_callbacks[callback_id]
622
- return {} unless callback
615
+ unless callback
616
+ logger.debug("protocol") { "Hook callback not found: #{callback_id}" }
617
+ return {}
618
+ end
619
+ logger.debug("protocol") { "Hook callback: #{callback_id}" }
623
620
 
624
621
  context = { tool_use_id: tool_use_id }
625
622
  result = callback.call(input, context)
@@ -634,6 +631,7 @@ module ClaudeAgent
634
631
  def handle_mcp_message(request)
635
632
  server_name = request["server_name"]
636
633
  message = request["message"]
634
+ logger.debug("protocol") { "MCP message for #{server_name}: #{message["method"]}" }
637
635
 
638
636
  # Find SDK MCP server
639
637
  server_config = options.mcp_servers[server_name]
@@ -667,6 +665,8 @@ module ClaudeAgent
667
665
  # @param result [Hash] Raw result from callback
668
666
  # @return [Hash] Normalized response
669
667
  def normalize_hook_response(result)
668
+ result = result.to_h
669
+
670
670
  response = HOOK_RESPONSE_KEYS.each_with_object({}) do |(ruby_key, json_key), acc|
671
671
  acc[json_key] = result[ruby_key] if result.key?(ruby_key)
672
672
  end
@@ -713,6 +713,7 @@ module ClaudeAgent
713
713
  @abort_signal&.check!
714
714
 
715
715
  request_id = generate_request_id
716
+ logger.debug("protocol") { "Sending control request: #{subtype} (#{request_id})" }
716
717
 
717
718
  request = {
718
719
  type: "control_request",
@@ -749,6 +750,7 @@ module ClaudeAgent
749
750
  end
750
751
 
751
752
  if response["subtype"] == "error"
753
+ logger.error("protocol") { "Control request failed: #{subtype} - #{response["error"]}" }
752
754
  raise Error, response["error"] || "Unknown error"
753
755
  end
754
756
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module ClaudeAgent
6
+ # Null logger that discards all output with zero overhead.
7
+ #
8
+ # All log methods return +true+ immediately without performing any I/O.
9
+ # This is the default logger, ensuring logging adds no cost when not configured.
10
+ #
11
+ # @example
12
+ # logger = ClaudeAgent::NullLogger.new
13
+ # logger.info("transport") { "This is discarded" } # => true
14
+ #
15
+ class NullLogger < Logger
16
+ def initialize
17
+ super(File::NULL)
18
+ @level = Logger::DEBUG
19
+ end
20
+
21
+ def add(_severity = nil, _message = nil, _progname = nil)
22
+ true
23
+ end
24
+
25
+ def debug(...) = true
26
+ def info(...) = true
27
+ def warn(...) = true
28
+ def error(...) = true
29
+ def fatal(...) = true
30
+ def unknown(...) = true
31
+
32
+ def debug? = false
33
+ def info? = false
34
+ def warn? = false
35
+ def error? = false
36
+ def fatal? = false
37
+ end
38
+
39
+ # Compact log formatter with gem name tag.
40
+ #
41
+ # Output format:
42
+ # [ClaudeAgent] [12:00:00.123] DEBUG -- transport: Spawning CLI
43
+ #
44
+ LOG_FORMATTER = proc do |severity, time, progname, msg|
45
+ "[ClaudeAgent] [#{time.strftime("%H:%M:%S.%L")}] #{severity.ljust(5)} -- #{progname}: #{msg}\n"
46
+ end
47
+
48
+ class << self
49
+ # Module-level logger used by all components unless overridden per-query.
50
+ #
51
+ # Defaults to {NullLogger} for zero overhead. Set to any +Logger+-compatible
52
+ # instance to enable logging.
53
+ #
54
+ # @return [Logger]
55
+ #
56
+ # @example
57
+ # ClaudeAgent.logger = Logger.new($stderr, level: :info)
58
+ #
59
+ def logger
60
+ @logger ||= default_logger
61
+ end
62
+
63
+ # Set the module-level logger.
64
+ #
65
+ # @param logger [Logger] A Logger-compatible instance
66
+ # @return [Logger]
67
+ def logger=(logger)
68
+ @logger = logger
69
+ end
70
+
71
+ # Enable debug-level logging to stderr (or a custom output).
72
+ #
73
+ # Convenience method for quick debugging. Creates a +Logger+ with
74
+ # a compact formatter and DEBUG level.
75
+ #
76
+ # @param output [IO] Output destination (default: +$stderr+)
77
+ # @return [Logger] The configured logger
78
+ #
79
+ # @example
80
+ # ClaudeAgent.debug!
81
+ # ClaudeAgent.debug!(output: $stdout)
82
+ # ClaudeAgent.debug!(output: File.open("debug.log", "a"))
83
+ #
84
+ def debug!(output: $stderr)
85
+ self.logger = Logger.new(output, level: Logger::DEBUG).tap do |l|
86
+ l.formatter = LOG_FORMATTER
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def default_logger
93
+ if ENV["CLAUDE_AGENT_DEBUG"]
94
+ Logger.new($stderr, level: Logger::DEBUG).tap do |l|
95
+ l.formatter = LOG_FORMATTER
96
+ end
97
+ else
98
+ NullLogger.new
99
+ end
100
+ end
101
+ end
102
+ end
@@ -34,9 +34,11 @@ module ClaudeAgent
34
34
 
35
35
  # @param name [String] Server name
36
36
  # @param tools [Array<Tool>] Tools to expose
37
- def initialize(name:, tools: [])
37
+ # @param logger [Logger, nil] Optional logger instance
38
+ def initialize(name:, tools: [], logger: nil)
38
39
  @name = name.to_s
39
40
  @tools = {}
41
+ @logger = logger
40
42
  tools.each { |tool| add_tool(tool) }
41
43
  end
42
44
 
@@ -61,6 +63,7 @@ module ClaudeAgent
61
63
  method = message["method"]
62
64
  params = message["params"] || {}
63
65
  id = message["id"]
66
+ logger.debug("mcp.#{@name}") { "Handling: #{method}" }
64
67
 
65
68
  result = case method
66
69
  when "initialize"
@@ -114,15 +117,21 @@ module ClaudeAgent
114
117
 
115
118
  tool = @tools[tool_name]
116
119
  unless tool
120
+ logger.warn("mcp.#{@name}") { "Unknown tool: #{tool_name}" }
117
121
  return {
118
122
  content: [ { type: "text", text: "Unknown tool: #{tool_name}" } ],
119
123
  isError: true
120
124
  }
121
125
  end
122
126
 
127
+ logger.info("mcp.#{@name}") { "Tool call: #{tool_name}" }
123
128
  tool.call(arguments)
124
129
  end
125
130
 
131
+ def logger
132
+ @logger || ClaudeAgent.logger
133
+ end
134
+
126
135
  def jsonrpc_response(id, result)
127
136
  {
128
137
  jsonrpc: "2.0",
@@ -8,6 +8,11 @@ module ClaudeAgent
8
8
  # message = parser.parse({"type" => "assistant", "message" => {...}})
9
9
  #
10
10
  class MessageParser
11
+ # @param logger [Logger, nil] Optional logger instance
12
+ def initialize(logger: nil)
13
+ @logger = logger
14
+ end
15
+
11
16
  # Parse a raw message hash into a typed message object
12
17
  #
13
18
  # @param raw [Hash] Raw message from CLI
@@ -15,6 +20,7 @@ module ClaudeAgent
15
20
  # @raise [MessageParseError] If message cannot be parsed
16
21
  def parse(raw)
17
22
  type = raw["type"]
23
+ logger.debug("parser") { "Parsing message: #{type}" }
18
24
 
19
25
  case type
20
26
  when "user"
@@ -52,12 +58,17 @@ module ClaudeAgent
52
58
  when "tool_use_summary"
53
59
  parse_tool_use_summary_message(raw)
54
60
  else
61
+ logger.error("parser") { "Unknown message type: #{type}" }
55
62
  raise MessageParseError.new("Unknown message type: #{type}", raw_message: raw)
56
63
  end
57
64
  end
58
65
 
59
66
  private
60
67
 
68
+ def logger
69
+ @logger || ClaudeAgent.logger
70
+ end
71
+
61
72
  # Fetch a value from a hash, trying both snake_case and camelCase keys
62
73
  # @param raw [Hash] The hash to fetch from
63
74
  # @param snake_key [Symbol, String] The snake_case key to try
@@ -62,6 +62,7 @@ module ClaudeAgent
62
62
  abort_controller spawn_claude_code_process
63
63
  init init_only maintenance
64
64
  debug debug_file
65
+ logger
65
66
  ].freeze
66
67
 
67
68
  attr_accessor(*ATTRIBUTES)
@@ -127,6 +128,12 @@ module ClaudeAgent
127
128
  abort_controller&.signal
128
129
  end
129
130
 
131
+ # Resolved logger: per-instance override or module-level default
132
+ # @return [Logger]
133
+ def effective_logger
134
+ @logger || ClaudeAgent.logger
135
+ end
136
+
130
137
  private
131
138
 
132
139
  # --- CLI Argument Builders ---
@@ -207,8 +214,7 @@ module ClaudeAgent
207
214
  [].tap do |args|
208
215
  args.push("--settings", settings) if settings
209
216
  if sandbox
210
- sandbox_json = sandbox.respond_to?(:to_h) ? sandbox.to_h : sandbox
211
- args.push("--sandbox", JSON.generate(sandbox_json))
217
+ args.push("--sandbox", JSON.generate(sandbox.to_h))
212
218
  end
213
219
  end
214
220
  end
@@ -234,7 +240,7 @@ module ClaudeAgent
234
240
  args.push("--json-schema", JSON.generate(output_format)) if output_format
235
241
  args.push("--include-partial-messages") if include_partial_messages
236
242
  if agents
237
- agents_hash = agents.transform_values { |a| a.respond_to?(:to_h) ? a.to_h : a }
243
+ agents_hash = agents.transform_values(&:to_h)
238
244
  args.push("--agents", JSON.generate(agents_hash))
239
245
  end
240
246
  end
@@ -280,6 +286,13 @@ module ClaudeAgent
280
286
  raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
281
287
  end
282
288
 
289
+ # Auto-set permission_prompt_tool_name to "stdio" when can_use_tool is configured
290
+ # (Python/TypeScript SDK parity) so the CLI routes permission prompts through the
291
+ # control protocol instead of interactive terminal prompts
292
+ if can_use_tool && !permission_prompt_tool_name
293
+ @permission_prompt_tool_name = "stdio"
294
+ end
295
+
283
296
  if max_turns && (!max_turns.is_a?(Integer) || max_turns < 1)
284
297
  raise ConfigurationError, "max_turns must be a positive integer"
285
298
  end
@@ -21,7 +21,7 @@ module ClaudeAgent
21
21
  def to_h
22
22
  h = { behavior: "allow" }
23
23
  h[:updatedInput] = updated_input if updated_input
24
- h[:updatedPermissions] = updated_permissions&.map { |p| p.respond_to?(:to_h) ? p.to_h : p } if updated_permissions
24
+ h[:updatedPermissions] = updated_permissions&.map(&:to_h) if updated_permissions
25
25
  h[:toolUseID] = tool_use_id if tool_use_id
26
26
  h
27
27
  end
@@ -85,46 +85,35 @@ module ClaudeAgent
85
85
  Enumerator.new do |yielder|
86
86
  # Set entrypoint environment variable
87
87
  ENV["CLAUDE_CODE_ENTRYPOINT"] = "sdk-rb"
88
+ query_logger = options.effective_logger
89
+ query_logger.info("query") { "Starting query" }
90
+ query_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
91
 
89
- # Determine mode based on hooks/MCP servers
90
- streaming = options.has_hooks? || options.has_sdk_mcp_servers?
91
-
92
- if streaming
93
- # Use streaming mode with control protocol
94
- protocol = ControlProtocol.new(transport: transport, options: options)
95
- begin
96
- # Register abort handler if abort controller is provided
97
- if options.abort_signal
98
- options.abort_signal.on_abort do
99
- protocol.abort! rescue nil
100
- end
92
+ # Always use streaming mode with control protocol (Python/TypeScript SDK parity)
93
+ protocol = ControlProtocol.new(transport: transport, options: options)
94
+ begin
95
+ # Register abort handler if abort controller is provided
96
+ if options.abort_signal
97
+ options.abort_signal.on_abort do
98
+ protocol.abort! rescue nil
101
99
  end
100
+ end
102
101
 
103
- protocol.start(streaming: true)
104
- protocol.send_user_message(prompt)
105
- transport.end_input unless options.has_hooks? || options.has_sdk_mcp_servers?
102
+ protocol.start(streaming: true)
103
+ protocol.send_user_message(prompt)
106
104
 
107
- protocol.each_message do |message|
108
- yielder << message
109
- break if message.is_a?(ResultMessage)
110
- end
111
- ensure
112
- protocol.stop
113
- end
114
- else
115
- # Simple mode - just send prompt and read responses
116
- parser = MessageParser.new
117
- begin
118
- transport.connect(streaming: false, prompt: prompt)
105
+ protocol.each_message do |message|
106
+ query_logger.debug("query") { "Received: #{message.class.name.split("::").last}" }
107
+ yielder << message
119
108
 
120
- transport.read_messages do |raw|
121
- message = parser.parse(raw)
122
- yielder << message
123
- break if message.is_a?(ResultMessage)
109
+ if message.is_a?(ResultMessage)
110
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - query_start
111
+ query_logger.info("query") { "Query complete (#{elapsed.round(2)}s, cost=$#{message.total_cost_usd || "?"})" }
112
+ break
124
113
  end
125
- ensure
126
- transport.close
127
114
  end
115
+ ensure
116
+ protocol.stop
128
117
  end
129
118
  end
130
119
  end
@@ -88,6 +88,9 @@ module ClaudeAgent
88
88
  end
89
89
 
90
90
  @connected = true
91
+ logger.info("transport") { "Process spawned (pid=#{@wait_thread&.pid || @process&.pid rescue "?"})" }
92
+ logger.debug("transport") { "Command: #{cmd.join(" ")}" }
93
+ logger.debug("transport") { "Working dir: #{working_directory}" }
91
94
 
92
95
  # Always start stderr reader to prevent pipe buffer from filling up
93
96
  start_stderr_reader if @stderr
@@ -115,6 +118,7 @@ module ClaudeAgent
115
118
  raise CLIConnectionError, "Not connected" unless @connected
116
119
  raise CLIConnectionError, "stdin closed" unless @stdin && !@stdin.closed?
117
120
 
121
+ logger.debug("transport") { "Write: #{data.bytesize} bytes" }
118
122
  @mutex.synchronize do
119
123
  @stdin.write(data)
120
124
  @stdin.write("\n") unless data.end_with?("\n")
@@ -139,8 +143,10 @@ module ClaudeAgent
139
143
 
140
144
  begin
141
145
  message = JSON.parse(line)
146
+ logger.debug("transport") { "Received: #{message["type"] || "unknown"}" }
142
147
  yield message
143
148
  rescue JSON::ParserError
149
+ logger.warn("transport") { "JSON parse error, buffering (#{@buffer.bytesize} bytes)" }
144
150
  # Buffer partial JSON (in case of split lines)
145
151
  @buffer << line
146
152
  if @buffer.bytesize > @max_buffer_size
@@ -192,6 +198,7 @@ module ClaudeAgent
192
198
  @connected = false
193
199
  @stdin = @stdout = @stderr = @wait_thread = nil
194
200
 
201
+ logger.info("transport") { "Transport closed (exit_status=#{exit_status.inspect})" }
195
202
  exit_status
196
203
  end
197
204
 
@@ -264,6 +271,7 @@ module ClaudeAgent
264
271
  # Force kill the CLI process (SIGKILL)
265
272
  # @return [void]
266
273
  def kill
274
+ logger.warn("transport") { "Force killing CLI process" }
267
275
  # Use custom process kill if available
268
276
  if @process&.respond_to?(:kill)
269
277
  @mutex.synchronize { @killed = true }
@@ -302,6 +310,10 @@ module ClaudeAgent
302
310
  paths.find { |p| !p.empty? && File.executable?(p) } || "claude"
303
311
  end
304
312
 
313
+ def logger
314
+ @options.effective_logger
315
+ end
316
+
305
317
  def check_cli_version!
306
318
  version_output = `#{@cli_path} -v 2>&1`.strip
307
319
  # Parse version like "claude 2.1.0" or just "2.1.0"
@@ -312,6 +324,7 @@ module ClaudeAgent
312
324
  end
313
325
 
314
326
  found_version = version_match[1]
327
+ logger.debug("transport") { "CLI version: #{found_version}" }
315
328
  unless version_satisfies?(found_version, MINIMUM_CLI_VERSION)
316
329
  raise CLIVersionError.new(found_version)
317
330
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgent
4
- VERSION = "0.7.3"
4
+ VERSION = "0.7.4"
5
5
  end
data/lib/claude_agent.rb CHANGED
@@ -4,6 +4,7 @@ require "active_support/core_ext/string/inflections"
4
4
  require "active_support/core_ext/hash/keys"
5
5
 
6
6
  require_relative "claude_agent/version"
7
+ require_relative "claude_agent/logging"
7
8
  require_relative "claude_agent/errors"
8
9
  require_relative "claude_agent/types" # TypeScript SDK parity types
9
10
  require_relative "claude_agent/sandbox_settings" # Sandbox configuration types
data/sig/claude_agent.rbs CHANGED
@@ -1,6 +1,28 @@
1
1
  module ClaudeAgent
2
2
  VERSION: String
3
3
 
4
+ # Null logger that discards all output with zero overhead
5
+ class NullLogger < Logger
6
+ def initialize: () -> void
7
+ def add: (?Integer? severity, ?untyped message, ?String? progname) -> true
8
+ def debug: (*untyped) -> true
9
+ def info: (*untyped) -> true
10
+ def warn: (*untyped) -> true
11
+ def error: (*untyped) -> true
12
+ def fatal: (*untyped) -> true
13
+ def unknown: (*untyped) -> true
14
+ def debug?: () -> false
15
+ def info?: () -> false
16
+ def warn?: () -> false
17
+ def error?: () -> false
18
+ def fatal?: () -> false
19
+ end
20
+
21
+ # Module-level logger
22
+ def self.logger: () -> Logger
23
+ def self.logger=: (Logger logger) -> Logger
24
+ def self.debug!: (?output: IO) -> Logger
25
+
4
26
  # One-shot query to Claude Code CLI
5
27
  def self.query: (
6
28
  prompt: String,
@@ -316,6 +338,9 @@ module ClaudeAgent
316
338
  attr_accessor debug: bool
317
339
  attr_accessor debug_file: String?
318
340
 
341
+ # Logging
342
+ attr_accessor logger: Logger?
343
+
319
344
  # Abort control (TypeScript SDK parity)
320
345
  attr_accessor abort_controller: AbortController?
321
346
 
@@ -328,6 +353,7 @@ module ClaudeAgent
328
353
  def has_hooks?: () -> bool
329
354
  def has_sdk_mcp_servers?: () -> bool
330
355
  def abort_signal: () -> AbortSignal?
356
+ def effective_logger: () -> Logger
331
357
  end
332
358
 
333
359
  # Content block types
@@ -646,7 +672,7 @@ module ClaudeAgent
646
672
 
647
673
  # Message parser
648
674
  class MessageParser
649
- def initialize: () -> void
675
+ def initialize: (?logger: Logger?) -> void
650
676
  def parse: (Hash[String, untyped] raw) -> message
651
677
  end
652
678
 
@@ -1029,7 +1055,7 @@ module ClaudeAgent
1029
1055
  attr_reader name: String
1030
1056
  attr_reader tools: Hash[String, Tool]
1031
1057
 
1032
- def initialize: (name: String, ?tools: Array[Tool]) -> void
1058
+ def initialize: (name: String, ?tools: Array[Tool], ?logger: Logger?) -> void
1033
1059
  def add_tool: (Tool tool) -> void
1034
1060
  def remove_tool: (String name) -> Tool?
1035
1061
  def handle_message: (Hash[String, untyped] message) -> Hash[Symbol, untyped]?
data/sig/manifest.yaml CHANGED
@@ -1,5 +1,6 @@
1
1
  dependencies:
2
2
  - name: json
3
+ - name: logger
3
4
  - name: timeout
4
5
  - name: open3
5
6
  - name: mutex_m
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Carr
@@ -56,6 +56,7 @@ files:
56
56
  - lib/claude_agent/control_protocol.rb
57
57
  - lib/claude_agent/errors.rb
58
58
  - lib/claude_agent/hooks.rb
59
+ - lib/claude_agent/logging.rb
59
60
  - lib/claude_agent/mcp/server.rb
60
61
  - lib/claude_agent/mcp/tool.rb
61
62
  - lib/claude_agent/message_parser.rb