From 098809ce6894a6d56220a36dd250747a9e6a74a3 Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:57:05 -0400 Subject: [PATCH] refactor: migrate to Zeitwerk autoloading Replace 48 manual require_relative calls with Zeitwerk autoloader. This enforces one-class-per-file organization, enables lazy loading, and eliminates brittle load-order dependencies. Key changes: - Split multi-class files into individual files (~50 new, ~20 deleted) - Collapse content_blocks/, messages/, types/, hooks/ directories - Configure inflections for MCP, CLI, JSON, SDK, API acronyms - Convert query.rb to ClaudeAgent::Query module - Move class methods from logging.rb/v2_session.rb to entry point - Add include Message directly in each message/content block class --- CHANGELOG.md | 8 + Gemfile.lock | 3 + claude_agent.gemspec | 1 + lib/claude_agent.rb | 318 ++++++++++++++---- lib/claude_agent/abort_controller.rb | 88 ----- lib/claude_agent/abort_error.rb | 31 ++ lib/claude_agent/abort_signal.rb | 91 +++++ lib/claude_agent/cli_connection_error.rb | 10 + lib/claude_agent/cli_not_found_error.rb | 10 + lib/claude_agent/cli_version_error.rb | 17 + lib/claude_agent/client.rb | 2 - lib/claude_agent/configuration_error.rb | 6 + lib/claude_agent/content_blocks.rb | 24 -- .../content_blocks/generic_block.rb | 2 + .../content_blocks/image_content_block.rb | 2 + .../server_tool_result_block.rb | 2 + .../content_blocks/server_tool_use_block.rb | 2 + lib/claude_agent/content_blocks/text_block.rb | 2 + .../content_blocks/thinking_block.rb | 2 + .../content_blocks/tool_result_block.rb | 2 + .../content_blocks/tool_use_block.rb | 2 + lib/claude_agent/control_protocol.rb | 6 - lib/claude_agent/error.rb | 6 + lib/claude_agent/errors.rb | 120 ------- lib/claude_agent/json_decode_error.rb | 15 + lib/claude_agent/local_spawned_process.rb | 128 +++++++ lib/claude_agent/logging.rb | 102 ------ lib/claude_agent/message.rb | 6 - lib/claude_agent/message_parse_error.rb | 15 + lib/claude_agent/messages.rb | 40 --- .../messages/api_retry_message.rb | 36 ++ .../messages/assistant_message.rb | 58 ++++ .../messages/auth_status_message.rb | 29 ++ .../messages/compact_boundary_message.rb | 41 +++ lib/claude_agent/messages/conversation.rb | 130 ------- .../messages/elicitation_complete_message.rb | 28 ++ .../messages/files_persisted_event.rb | 32 ++ .../{generic.rb => generic_message.rb} | 2 + .../messages/hook_progress_message.rb | 36 ++ ..._lifecycle.rb => hook_response_message.rb} | 58 +--- .../messages/hook_started_message.rb | 30 ++ .../messages/local_command_output_message.rb | 26 ++ .../messages/prompt_suggestion_message.rb | 26 ++ lib/claude_agent/messages/rate_limit_event.rb | 40 +++ .../messages/{result.rb => result_message.rb} | 2 + lib/claude_agent/messages/status_message.rb | 27 ++ .../{streaming.rb => stream_event.rb} | 58 +--- lib/claude_agent/messages/system.rb | 106 ------ lib/claude_agent/messages/system_message.rb | 19 ++ lib/claude_agent/messages/task_lifecycle.rb | 189 ----------- .../messages/task_notification_message.rb | 60 ++++ .../messages/task_progress_message.rb | 34 ++ .../messages/task_started_message.rb | 33 ++ lib/claude_agent/messages/tool_lifecycle.rb | 74 ---- .../messages/tool_progress_message.rb | 32 ++ .../messages/tool_use_summary_message.rb | 28 ++ lib/claude_agent/messages/user_message.rb | 33 ++ .../messages/user_message_replay.rb | 51 +++ lib/claude_agent/not_found_error.rb | 6 + lib/claude_agent/null_logger.rb | 38 +++ lib/claude_agent/options.rb | 2 - lib/claude_agent/permission_result_allow.rb | 29 ++ lib/claude_agent/permission_result_deny.rb | 28 ++ lib/claude_agent/permission_rule_value.rb | 18 + lib/claude_agent/permission_update.rb | 43 +++ lib/claude_agent/permissions.rb | 155 --------- lib/claude_agent/process_error.rb | 17 + lib/claude_agent/query.rb | 7 +- lib/claude_agent/sandbox_filesystem_config.rb | 32 ++ lib/claude_agent/sandbox_ignore_violations.rb | 23 ++ lib/claude_agent/sandbox_network_config.rb | 34 ++ lib/claude_agent/sandbox_ripgrep_config.rb | 22 ++ lib/claude_agent/sandbox_settings.rb | 99 ------ lib/claude_agent/session_options.rb | 24 ++ lib/claude_agent/spawn.rb | 237 ------------- lib/claude_agent/spawn_options.rb | 29 ++ lib/claude_agent/spawned_process.rb | 70 ++++ lib/claude_agent/timeout_error.rb | 17 + lib/claude_agent/tool_permission_context.rb | 30 ++ lib/claude_agent/types.rb | 25 -- lib/claude_agent/types/account_info.rb | 16 + lib/claude_agent/types/agent_definition.rb | 48 +++ lib/claude_agent/types/agent_info.rb | 16 + lib/claude_agent/types/fork_session_result.rb | 13 + .../types/initialization_result.rb | 25 ++ .../types/{mcp.rb => mcp_server_status.rb} | 15 - .../types/mcp_set_servers_result.rb | 18 + lib/claude_agent/types/model_info.rb | 21 ++ lib/claude_agent/types/model_usage.rb | 19 ++ lib/claude_agent/types/models.rb | 130 ------- lib/claude_agent/types/operations.rb | 40 --- lib/claude_agent/types/rewind_files_result.rb | 21 ++ .../types/sdk_permission_denial.rb | 11 + .../types/{sessions.rb => session_info.rb} | 28 -- lib/claude_agent/types/session_message.rb | 21 ++ .../types/{tools.rb => slash_command.rb} | 15 - lib/claude_agent/types/task_usage.rb | 14 + lib/claude_agent/types/tools_preset.rb | 18 + lib/claude_agent/v2_session.rb | 97 ------ 99 files changed, 2035 insertions(+), 1917 deletions(-) create mode 100644 lib/claude_agent/abort_error.rb create mode 100644 lib/claude_agent/abort_signal.rb create mode 100644 lib/claude_agent/cli_connection_error.rb create mode 100644 lib/claude_agent/cli_not_found_error.rb create mode 100644 lib/claude_agent/cli_version_error.rb create mode 100644 lib/claude_agent/configuration_error.rb delete mode 100644 lib/claude_agent/content_blocks.rb create mode 100644 lib/claude_agent/error.rb delete mode 100644 lib/claude_agent/errors.rb create mode 100644 lib/claude_agent/json_decode_error.rb create mode 100644 lib/claude_agent/local_spawned_process.rb delete mode 100644 lib/claude_agent/logging.rb create mode 100644 lib/claude_agent/message_parse_error.rb delete mode 100644 lib/claude_agent/messages.rb create mode 100644 lib/claude_agent/messages/api_retry_message.rb create mode 100644 lib/claude_agent/messages/assistant_message.rb create mode 100644 lib/claude_agent/messages/auth_status_message.rb create mode 100644 lib/claude_agent/messages/compact_boundary_message.rb delete mode 100644 lib/claude_agent/messages/conversation.rb create mode 100644 lib/claude_agent/messages/elicitation_complete_message.rb create mode 100644 lib/claude_agent/messages/files_persisted_event.rb rename lib/claude_agent/messages/{generic.rb => generic_message.rb} (97%) create mode 100644 lib/claude_agent/messages/hook_progress_message.rb rename lib/claude_agent/messages/{hook_lifecycle.rb => hook_response_message.rb} (54%) create mode 100644 lib/claude_agent/messages/hook_started_message.rb create mode 100644 lib/claude_agent/messages/local_command_output_message.rb create mode 100644 lib/claude_agent/messages/prompt_suggestion_message.rb create mode 100644 lib/claude_agent/messages/rate_limit_event.rb rename lib/claude_agent/messages/{result.rb => result_message.rb} (98%) create mode 100644 lib/claude_agent/messages/status_message.rb rename lib/claude_agent/messages/{streaming.rb => stream_event.rb} (58%) delete mode 100644 lib/claude_agent/messages/system.rb create mode 100644 lib/claude_agent/messages/system_message.rb delete mode 100644 lib/claude_agent/messages/task_lifecycle.rb create mode 100644 lib/claude_agent/messages/task_notification_message.rb create mode 100644 lib/claude_agent/messages/task_progress_message.rb create mode 100644 lib/claude_agent/messages/task_started_message.rb delete mode 100644 lib/claude_agent/messages/tool_lifecycle.rb create mode 100644 lib/claude_agent/messages/tool_progress_message.rb create mode 100644 lib/claude_agent/messages/tool_use_summary_message.rb create mode 100644 lib/claude_agent/messages/user_message.rb create mode 100644 lib/claude_agent/messages/user_message_replay.rb create mode 100644 lib/claude_agent/not_found_error.rb create mode 100644 lib/claude_agent/null_logger.rb create mode 100644 lib/claude_agent/permission_result_allow.rb create mode 100644 lib/claude_agent/permission_result_deny.rb create mode 100644 lib/claude_agent/permission_rule_value.rb create mode 100644 lib/claude_agent/permission_update.rb delete mode 100644 lib/claude_agent/permissions.rb create mode 100644 lib/claude_agent/process_error.rb create mode 100644 lib/claude_agent/sandbox_filesystem_config.rb create mode 100644 lib/claude_agent/sandbox_ignore_violations.rb create mode 100644 lib/claude_agent/sandbox_network_config.rb create mode 100644 lib/claude_agent/sandbox_ripgrep_config.rb create mode 100644 lib/claude_agent/session_options.rb delete mode 100644 lib/claude_agent/spawn.rb create mode 100644 lib/claude_agent/spawn_options.rb create mode 100644 lib/claude_agent/spawned_process.rb create mode 100644 lib/claude_agent/timeout_error.rb create mode 100644 lib/claude_agent/tool_permission_context.rb delete mode 100644 lib/claude_agent/types.rb create mode 100644 lib/claude_agent/types/account_info.rb create mode 100644 lib/claude_agent/types/agent_definition.rb create mode 100644 lib/claude_agent/types/agent_info.rb create mode 100644 lib/claude_agent/types/fork_session_result.rb create mode 100644 lib/claude_agent/types/initialization_result.rb rename lib/claude_agent/types/{mcp.rb => mcp_server_status.rb} (59%) create mode 100644 lib/claude_agent/types/mcp_set_servers_result.rb create mode 100644 lib/claude_agent/types/model_info.rb create mode 100644 lib/claude_agent/types/model_usage.rb delete mode 100644 lib/claude_agent/types/models.rb delete mode 100644 lib/claude_agent/types/operations.rb create mode 100644 lib/claude_agent/types/rewind_files_result.rb create mode 100644 lib/claude_agent/types/sdk_permission_denial.rb rename lib/claude_agent/types/{sessions.rb => session_info.rb} (52%) create mode 100644 lib/claude_agent/types/session_message.rb rename lib/claude_agent/types/{tools.rb => slash_command.rb} (55%) create mode 100644 lib/claude_agent/types/task_usage.rb create mode 100644 lib/claude_agent/types/tools_preset.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 316bf56..7948a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- Migrated from manual `require_relative` loading to Zeitwerk autoloading +- Split multi-class files into one-class-per-file (Zeitwerk convention) +- Collapsed `content_blocks/`, `messages/`, `types/`, `hooks/` directories (classes remain in `ClaudeAgent::` namespace) +- Converted `ClaudeAgent.query`/`query_turn` to a `ClaudeAgent::Query` module extended into the singleton class +- Moved logging and V2 session class methods into the main entry point - Replaced all 63 `Data.define` types with plain class inheritance from `ImmutableRecord` base class - `Message` module is now `include`d instead of `prepend`ed into message and content block types - RBS signatures now use real supertype inheritance (`< ImmutableRecord`) @@ -17,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved hooks code into `hooks/` directory (`hook.rb`, `hook_context.rb`, `hook_input.rb`, `hook_registry.rb`) ### Added +- `zeitwerk` (~> 2.7) runtime dependency for autoloading +- `ClaudeAgent.loader` accessor for the Zeitwerk loader instance (useful for `eager_load` in production) +- `include Message` directly in each message and content block class (replaces iteration over `MESSAGE_TYPES`/`CONTENT_BLOCK_TYPES`) - `HookRegistry.register(cli_event)` — registers a new hook event, generating both a `*Hook` subclass and a DSL method by convention - `HookRegistry.wrap(input)` — normalizes `HookRegistry`, `Hash`, or `nil` into a `HookRegistry` - `HookRegistry.from_hash(hash)` — builds a registry from a raw hooks hash diff --git a/Gemfile.lock b/Gemfile.lock index ba52775..1164193 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: claude_agent (0.7.18) activesupport (>= 7.0) + zeitwerk (~> 2.7) GEM remote: https://rubygems.org/ @@ -105,6 +106,7 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.2.0) uri (1.1.1) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-25 @@ -168,6 +170,7 @@ CHECKSUMS unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd BUNDLED WITH 4.0.3 diff --git a/claude_agent.gemspec b/claude_agent.gemspec index ae207e9..49835a3 100644 --- a/claude_agent.gemspec +++ b/claude_agent.gemspec @@ -38,4 +38,5 @@ Gem::Specification.new do |spec| # Runtime dependencies spec.add_dependency "activesupport", ">= 7.0" + spec.add_dependency "zeitwerk", "~> 2.7" end diff --git a/lib/claude_agent.rb b/lib/claude_agent.rb index 82ead66..c7f285d 100644 --- a/lib/claude_agent.rb +++ b/lib/claude_agent.rb @@ -1,63 +1,170 @@ # frozen_string_literal: true +require "zeitwerk" require "active_support/core_ext/string/inflections" require "active_support/core_ext/hash/keys" -require_relative "claude_agent/version" -require_relative "claude_agent/logging" -require_relative "claude_agent/errors" -require_relative "claude_agent/immutable_record" # Base class for all immutable value types -require_relative "claude_agent/types" # TypeScript SDK parity types -require_relative "claude_agent/sandbox_settings" # Sandbox configuration types -require_relative "claude_agent/abort_controller" # Abort/cancel support (TypeScript SDK parity) -require_relative "claude_agent/spawn" # Custom spawn support (TypeScript SDK parity) -require_relative "claude_agent/options" -require_relative "claude_agent/content_blocks" -require_relative "claude_agent/messages" -require_relative "claude_agent/message" # Shared interface module for all message/block types -require_relative "claude_agent/permission_policy" # Declarative permission DSL -require_relative "claude_agent/hooks/hook" -require_relative "claude_agent/hooks/hook_context" -require_relative "claude_agent/hooks/hook_input" -require_relative "claude_agent/hooks/hook_registry" -require_relative "claude_agent/message_parser" -require_relative "claude_agent/permissions" -require_relative "claude_agent/permission_request" -require_relative "claude_agent/permission_queue" -require_relative "claude_agent/control_protocol" -require_relative "claude_agent/transport/base" -require_relative "claude_agent/transport/subprocess" -require_relative "claude_agent/mcp/tool" -require_relative "claude_agent/mcp/server" -require_relative "claude_agent/cumulative_usage" -require_relative "claude_agent/event_handler" -require_relative "claude_agent/turn_result" -require_relative "claude_agent/tool_activity" -require_relative "claude_agent/live_tool_activity" -require_relative "claude_agent/tool_activity_tracker" -require_relative "claude_agent/query" -require_relative "claude_agent/client" -require_relative "claude_agent/conversation" -require_relative "claude_agent/session_paths" # Shared session path infrastructure -require_relative "claude_agent/list_sessions" # Session discovery (TypeScript SDK v0.2.53 parity) -require_relative "claude_agent/get_session_messages" # Session transcript reading (TypeScript SDK v0.2.59 parity) -require_relative "claude_agent/session_message_relation" # Chainable message query object -require_relative "claude_agent/session_mutations" # Session rename/tag mutations -require_relative "claude_agent/get_session_info" # Single session lookup -require_relative "claude_agent/fork_session" # Session forking (TypeScript SDK v0.2.76 parity) -require_relative "claude_agent/v2_session" # V2 Session API (unstable) -require_relative "claude_agent/configuration" # Stripe-style global config -require_relative "claude_agent/session" # Session finder - module ClaudeAgent - require "forwardable" + LOADER = Zeitwerk::Loader.for_gem + LOADER.inflector.inflect( + "mcp" => "MCP", + "cli_not_found_error" => "CLINotFoundError", + "cli_version_error" => "CLIVersionError", + "cli_connection_error" => "CLIConnectionError", + "json_decode_error" => "JSONDecodeError", + "sdk_permission_denial" => "SDKPermissionDenial", + "api_retry_message" => "APIRetryMessage", + "v2_session" => "V2Session" + ) + LOADER.collapse("#{__dir__}/claude_agent/content_blocks") + LOADER.collapse("#{__dir__}/claude_agent/messages") + LOADER.collapse("#{__dir__}/claude_agent/types") + LOADER.collapse("#{__dir__}/claude_agent/hooks") + LOADER.setup + + class << self + # @return [Zeitwerk::Loader] + def loader + LOADER + end + end + + # --- Aggregate constants --- + + CONTENT_BLOCK_TYPES = [ + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + ServerToolUseBlock, + ServerToolResultBlock, + ImageContentBlock, + GenericBlock + ].freeze + + MESSAGE_TYPES = [ + UserMessage, + UserMessageReplay, + AssistantMessage, + SystemMessage, + ResultMessage, + StreamEvent, + CompactBoundaryMessage, + StatusMessage, + ToolProgressMessage, + HookResponseMessage, + AuthStatusMessage, + TaskNotificationMessage, + HookStartedMessage, + HookProgressMessage, + ToolUseSummaryMessage, + FilesPersistedEvent, + TaskStartedMessage, + TaskProgressMessage, + RateLimitEvent, + PromptSuggestionMessage, + ElicitationCompleteMessage, + LocalCommandOutputMessage, + APIRetryMessage, + GenericMessage + ].freeze + + ASSISTANT_MESSAGE_ERROR_TYPES = %w[ + authentication_failed + billing_error + rate_limit + invalid_request + server_error + unknown + max_output_tokens + ].freeze + + API_KEY_SOURCES = %w[user project org temporary oauth].freeze + + PERMISSION_UPDATE_TYPES = %w[ + addRules + replaceRules + removeRules + setMode + addDirectories + removeDirectories + ].freeze + + PERMISSION_UPDATE_DESTINATIONS = %w[ + userSettings + projectSettings + localSettings + session + cliArg + ].freeze + + # --- Logging --- + + LOG_FORMATTER = proc do |severity, time, progname, msg| + "[ClaudeAgent] [#{time.strftime("%H:%M:%S.%L")}] #{severity.ljust(5)} -- #{progname}: #{msg}\n" + end + + # Default spawn function for local subprocess execution + DEFAULT_SPAWN = ->(spawn_options) { + LocalSpawnedProcess.spawn(spawn_options) + }.freeze + + # --- Global configuration --- @config = Configuration.setup + require "forwardable" + class << self extend Forwardable + include Query + attr_reader :config + # --- Logger methods --- + + # Module-level logger used by all components unless overridden per-query. + # + # Defaults to {NullLogger} for zero overhead. Set to any +Logger+-compatible + # instance to enable logging. + # + # @return [Logger] + # + # @example + # ClaudeAgent.logger = Logger.new($stderr, level: :info) + # + def logger + @logger ||= default_logger + end + + # Set the module-level logger. + # + # @param logger [Logger] A Logger-compatible instance + # @return [Logger] + def logger=(logger) + @logger = logger + end + + # Enable debug-level logging to stderr (or a custom output). + # + # Convenience method for quick debugging. Creates a +Logger+ with + # a compact formatter and DEBUG level. + # + # @param output [IO] Output destination (default: +$stderr+) + # @return [Logger] The configured logger + # + # @example + # ClaudeAgent.debug! + # ClaudeAgent.debug!(output: $stdout) + # ClaudeAgent.debug!(output: File.open("debug.log", "a")) + # + def debug!(output: $stderr) + require "logger" + self.logger = Logger.new(output, level: Logger::DEBUG).tap do |l| + l.formatter = LOG_FORMATTER + end + end + # --- Tier 1 delegators (set once at boot) --- def_delegators :@config, :model, :model=, @@ -185,14 +292,9 @@ def conversation(**kwargs) # List past sessions with metadata # - # Reads session metadata directly from disk without spawning a CLI subprocess. - # Returns SessionInfo objects sorted by last modified time (most recent first). - # - # @param dir [String, nil] Directory to scope sessions to (includes git worktrees). - # When nil, returns sessions from all projects. - # @param limit [Integer, nil] Maximum number of sessions to return. - # @param include_worktrees [Boolean] When dir is in a git repo, include sessions - # from all git worktree paths. Defaults to true. + # @param dir [String, nil] Directory to scope sessions to + # @param limit [Integer, nil] Maximum number of sessions to return + # @param include_worktrees [Boolean] Include git worktree sessions # @return [Array] def list_sessions(dir: nil, limit: nil, offset: nil, include_worktrees: true) ListSessions.call(dir: dir, limit: limit, offset: offset, include_worktrees: include_worktrees) @@ -200,34 +302,30 @@ def list_sessions(dir: nil, limit: nil, offset: nil, include_worktrees: true) # Read messages from a past session's transcript # - # Reads the session JSONL file from disk, reconstructs the main conversation - # thread, and returns user/assistant messages with optional pagination. - # # @param session_id [String] UUID of the session to read - # @param dir [String, nil] Project directory to find the session in. - # When nil, searches all projects. - # @param limit [Integer, nil] Maximum number of messages to return. - # @param offset [Integer, nil] Number of messages to skip from the start. + # @param dir [String, nil] Project directory + # @param limit [Integer, nil] Maximum number of messages + # @param offset [Integer, nil] Number of messages to skip # @return [Array] def get_session_messages(session_id, dir: nil, limit: nil, offset: nil) GetSessionMessages.call(session_id, dir: dir, limit: limit, offset: offset) end - # Rename a session by appending a custom-title entry to its file. + # Rename a session # - # @param session_id [String] UUID of the session to rename + # @param session_id [String] UUID of the session # @param title [String] New title - # @param dir [String, nil] Project directory to scope the search + # @param dir [String, nil] Project directory # @return [void] def rename_session(session_id, title, dir: nil) SessionMutations.rename_session(session_id, title, dir: dir) end - # Tag a session by appending a tag entry to its file. + # Tag a session # - # @param session_id [String] UUID of the session to tag - # @param tag [String, nil] Tag value. Pass nil to clear. - # @param dir [String, nil] Project directory to scope the search + # @param session_id [String] UUID of the session + # @param tag [String, nil] Tag value + # @param dir [String, nil] Project directory # @return [void] def tag_session(session_id, tag, dir: nil) SessionMutations.tag_session(session_id, tag, dir: dir) @@ -235,19 +333,19 @@ def tag_session(session_id, tag, dir: nil) # Look up a single session by ID. # - # @param session_id [String] UUID of the session - # @param dir [String, nil] Project directory to scope the search + # @param session_id [String] UUID + # @param dir [String, nil] Project directory # @return [SessionInfo, nil] def get_session_info(session_id, dir: nil) GetSessionInfo.call(session_id, dir: dir) end - # Fork a session by creating a new session file with remapped UUIDs. + # Fork a session # # @param session_id [String] UUID of the source session - # @param up_to_message_id [String, nil] Truncate at this message UUID (inclusive) + # @param up_to_message_id [String, nil] Truncate at this message UUID # @param title [String, nil] Title for the forked session - # @param dir [String, nil] Project directory to find the session in + # @param dir [String, nil] Project directory # @return [ForkSessionResult] def fork_session(session_id, up_to_message_id: nil, title: nil, dir: nil) ForkSession.call(session_id, up_to_message_id: up_to_message_id, title: title, dir: dir) @@ -262,6 +360,67 @@ def resume_conversation(session_id, **kwargs) Conversation.resume(session_id, **kwargs) end + # --- V2 Session API (unstable) --- + + # V2 API - UNSTABLE + # Create a persistent session for multi-turn conversations. + # + # @param options [Hash, SessionOptions] Session configuration + # @return [V2Session] + # @alpha + def unstable_v2_create_session(options) + V2Session.new(options) + end + + # V2 API - UNSTABLE + # Resume an existing session by ID. + # + # @param session_id [String] The session ID to resume + # @param options [Hash, SessionOptions] Session configuration + # @return [V2Session] + # @alpha + def unstable_v2_resume_session(session_id, options) + session = V2Session.new(options) + session.instance_variable_set(:@resume_session_id, session_id) + + session.define_singleton_method(:build_client_options) do + Options.new( + model: @options.model, + cli_path: @options.path_to_claude_code_executable, + env: @options.env, + allowed_tools: @options.allowed_tools, + disallowed_tools: @options.disallowed_tools, + can_use_tool: @options.can_use_tool, + hooks: @options.hooks, + permission_mode: @options.permission_mode, + resume: @resume_session_id + ) + end + + session + end + + # V2 API - UNSTABLE + # One-shot convenience function for single prompts. + # + # @param message [String] The prompt message + # @param options [Hash, SessionOptions] Session configuration + # @return [ResultMessage] + # @alpha + def unstable_v2_prompt(message, options) + session = unstable_v2_create_session(options) + begin + session.send(message) + result = nil + session.stream.each do |msg| + result = msg if msg.is_a?(ResultMessage) + end + result + ensure + session.close + end + end + private # Separate on_* callbacks from config overrides in kwargs. @@ -308,5 +467,16 @@ def merge_config_into_kwargs(kwargs) # Per-request kwargs override config merged.merge(kwargs) end + + def default_logger + require "logger" + if ENV["CLAUDE_AGENT_DEBUG"] + Logger.new($stderr, level: Logger::DEBUG).tap do |l| + l.formatter = LOG_FORMATTER + end + else + NullLogger.new + end + end end end diff --git a/lib/claude_agent/abort_controller.rb b/lib/claude_agent/abort_controller.rb index b744e89..ccdd629 100644 --- a/lib/claude_agent/abort_controller.rb +++ b/lib/claude_agent/abort_controller.rb @@ -46,92 +46,4 @@ def reset! @signal.reset! end end - - # Signal object that tracks abort state (TypeScript SDK parity) - # - # Thread-safe signal that can be checked by multiple consumers - # and triggers callbacks when aborted. - # - class AbortSignal - def initialize - @aborted = false - @reason = nil - @mutex = Mutex.new - @condition = ConditionVariable.new - @callbacks = [] - end - - # Check if signal has been aborted - # @return [Boolean] - def aborted? - @mutex.synchronize { @aborted } - end - - # Get the abort reason - # @return [String, nil] - def reason - @mutex.synchronize { @reason } - end - - # Register a callback for when abort is triggered - # @yield [reason] Called when abort occurs - # @return [void] - def on_abort(&block) - @mutex.synchronize do - if @aborted - block.call(@reason) - else - @callbacks << block - end - end - end - - # Wait until aborted (with optional timeout) - # @param timeout [Numeric, nil] Timeout in seconds - # @return [Boolean] True if aborted, false if timed out - def wait(timeout: nil) - @mutex.synchronize do - return true if @aborted - - @condition.wait(@mutex, timeout) - @aborted - end - end - - # Raise AbortError if aborted (for checking in loops) - # @raise [AbortError] If signal has been aborted - def check! - raise AbortError, reason if aborted? - end - - # Reset the signal so the controller can be reused. - # - # No-op if the signal has not been aborted. - # - # @return [void] - def reset! - @mutex.synchronize do - return unless @aborted - @aborted = false - @reason = nil - end - end - - # @api private - # Trigger the abort - # @param reason [String, nil] Reason for aborting - def abort!(reason = nil) - callbacks_to_call = [] - @mutex.synchronize do - return if @aborted - - @aborted = true - @reason = reason || "Operation was aborted" - callbacks_to_call = @callbacks.dup - @callbacks.clear - @condition.broadcast - end - callbacks_to_call.each { |cb| cb.call(@reason) } - end - end end diff --git a/lib/claude_agent/abort_error.rb b/lib/claude_agent/abort_error.rb new file mode 100644 index 0000000..7138a07 --- /dev/null +++ b/lib/claude_agent/abort_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when an operation is aborted/cancelled (TypeScript SDK parity) + # + # This error is raised when an operation is explicitly cancelled, + # such as through a user interrupt or abort signal. + # + # When raised from {Client#receive_turn} or {Conversation#say}, + # carries a partial {TurnResult} with whatever was accumulated + # before cancellation (text, tool executions, usage). + # + # @example Accessing partial results + # begin + # turn = conversation.say("Fix the bug") + # rescue ClaudeAgent::AbortError => e + # turn = e.partial_turn + # puts turn.text # accumulated text (from stream events if needed) + # puts turn.tool_uses # tools that were called before abort + # end + # + class AbortError < Error + # @return [TurnResult, nil] Partial turn accumulated before abort + attr_reader :partial_turn + + def initialize(message = "Operation was aborted", partial_turn: nil) + @partial_turn = partial_turn + super(message) + end + end +end diff --git a/lib/claude_agent/abort_signal.rb b/lib/claude_agent/abort_signal.rb new file mode 100644 index 0000000..3df2865 --- /dev/null +++ b/lib/claude_agent/abort_signal.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Signal object that tracks abort state (TypeScript SDK parity) + # + # Thread-safe signal that can be checked by multiple consumers + # and triggers callbacks when aborted. + # + class AbortSignal + def initialize + @aborted = false + @reason = nil + @mutex = Mutex.new + @condition = ConditionVariable.new + @callbacks = [] + end + + # Check if signal has been aborted + # @return [Boolean] + def aborted? + @mutex.synchronize { @aborted } + end + + # Get the abort reason + # @return [String, nil] + def reason + @mutex.synchronize { @reason } + end + + # Register a callback for when abort is triggered + # @yield [reason] Called when abort occurs + # @return [void] + def on_abort(&block) + @mutex.synchronize do + if @aborted + block.call(@reason) + else + @callbacks << block + end + end + end + + # Wait until aborted (with optional timeout) + # @param timeout [Numeric, nil] Timeout in seconds + # @return [Boolean] True if aborted, false if timed out + def wait(timeout: nil) + @mutex.synchronize do + return true if @aborted + + @condition.wait(@mutex, timeout) + @aborted + end + end + + # Raise AbortError if aborted (for checking in loops) + # @raise [AbortError] If signal has been aborted + def check! + raise AbortError, reason if aborted? + end + + # Reset the signal so the controller can be reused. + # + # No-op if the signal has not been aborted. + # + # @return [void] + def reset! + @mutex.synchronize do + return unless @aborted + @aborted = false + @reason = nil + end + end + + # @api private + # Trigger the abort + # @param reason [String, nil] Reason for aborting + def abort!(reason = nil) + callbacks_to_call = [] + @mutex.synchronize do + return if @aborted + + @aborted = true + @reason = reason || "Operation was aborted" + callbacks_to_call = @callbacks.dup + @callbacks.clear + @condition.broadcast + end + callbacks_to_call.each { |cb| cb.call(@reason) } + end + end +end diff --git a/lib/claude_agent/cli_connection_error.rb b/lib/claude_agent/cli_connection_error.rb new file mode 100644 index 0000000..9f8c0bc --- /dev/null +++ b/lib/claude_agent/cli_connection_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when connection to CLI fails + class CLIConnectionError < Error + def initialize(message = "Failed to connect to Claude Code CLI") + super + end + end +end diff --git a/lib/claude_agent/cli_not_found_error.rb b/lib/claude_agent/cli_not_found_error.rb new file mode 100644 index 0000000..5e7c801 --- /dev/null +++ b/lib/claude_agent/cli_not_found_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when the Claude Code CLI cannot be found + class CLINotFoundError < Error + def initialize(message = "Claude Code CLI not found. Please install it first.") + super + end + end +end diff --git a/lib/claude_agent/cli_version_error.rb b/lib/claude_agent/cli_version_error.rb new file mode 100644 index 0000000..bf58bed --- /dev/null +++ b/lib/claude_agent/cli_version_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when the CLI version is below minimum required + class CLIVersionError < Error + MINIMUM_VERSION = "2.0.0" + + def initialize(found_version = nil) + message = if found_version + "Claude Code CLI version #{found_version} is below minimum required version #{MINIMUM_VERSION}" + else + "Could not determine Claude Code CLI version. Minimum required: #{MINIMUM_VERSION}" + end + super(message) + end + end +end diff --git a/lib/claude_agent/client.rb b/lib/claude_agent/client.rb index 114161b..c51bb72 100644 --- a/lib/claude_agent/client.rb +++ b/lib/claude_agent/client.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "client/commands" - module ClaudeAgent # Interactive, bidirectional client for Claude Code CLI # diff --git a/lib/claude_agent/configuration_error.rb b/lib/claude_agent/configuration_error.rb new file mode 100644 index 0000000..776a601 --- /dev/null +++ b/lib/claude_agent/configuration_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when an invalid option is provided + class ConfigurationError < Error; end +end diff --git a/lib/claude_agent/content_blocks.rb b/lib/claude_agent/content_blocks.rb deleted file mode 100644 index a21b4fb..0000000 --- a/lib/claude_agent/content_blocks.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require_relative "content_blocks/text_block" -require_relative "content_blocks/thinking_block" -require_relative "content_blocks/tool_use_block" -require_relative "content_blocks/tool_result_block" -require_relative "content_blocks/server_tool_use_block" -require_relative "content_blocks/server_tool_result_block" -require_relative "content_blocks/image_content_block" -require_relative "content_blocks/generic_block" - -module ClaudeAgent - # All content block types - CONTENT_BLOCK_TYPES = [ - TextBlock, - ThinkingBlock, - ToolUseBlock, - ToolResultBlock, - ServerToolUseBlock, - ServerToolResultBlock, - ImageContentBlock, - GenericBlock - ].freeze -end diff --git a/lib/claude_agent/content_blocks/generic_block.rb b/lib/claude_agent/content_blocks/generic_block.rb index 94c98fa..8600769 100644 --- a/lib/claude_agent/content_blocks/generic_block.rb +++ b/lib/claude_agent/content_blocks/generic_block.rb @@ -15,6 +15,8 @@ module ClaudeAgent # block.to_h # => { text: "ref", url: "https://example.com" } # class GenericBlock < ImmutableRecord + include Message + attribute :block_type attribute :raw diff --git a/lib/claude_agent/content_blocks/image_content_block.rb b/lib/claude_agent/content_blocks/image_content_block.rb index d562b74..d366989 100644 --- a/lib/claude_agent/content_blocks/image_content_block.rb +++ b/lib/claude_agent/content_blocks/image_content_block.rb @@ -19,6 +19,8 @@ module ClaudeAgent # block.url # => "https://example.com/image.png" # class ImageContentBlock < ImmutableRecord + include Message + attribute :source def type diff --git a/lib/claude_agent/content_blocks/server_tool_result_block.rb b/lib/claude_agent/content_blocks/server_tool_result_block.rb index 08ae57d..2eee430 100644 --- a/lib/claude_agent/content_blocks/server_tool_result_block.rb +++ b/lib/claude_agent/content_blocks/server_tool_result_block.rb @@ -4,6 +4,8 @@ module ClaudeAgent # Server tool result block # class ServerToolResultBlock < ImmutableRecord + include Message + attribute :tool_use_id attribute :server_name attribute :content, default: nil diff --git a/lib/claude_agent/content_blocks/server_tool_use_block.rb b/lib/claude_agent/content_blocks/server_tool_use_block.rb index dd3c9f3..edd2e75 100644 --- a/lib/claude_agent/content_blocks/server_tool_use_block.rb +++ b/lib/claude_agent/content_blocks/server_tool_use_block.rb @@ -4,6 +4,8 @@ module ClaudeAgent # Server tool use block (for MCP servers) # class ServerToolUseBlock < ImmutableRecord + include Message + attribute :id attribute :name attribute :input diff --git a/lib/claude_agent/content_blocks/text_block.rb b/lib/claude_agent/content_blocks/text_block.rb index 4b19dda..f835f13 100644 --- a/lib/claude_agent/content_blocks/text_block.rb +++ b/lib/claude_agent/content_blocks/text_block.rb @@ -8,6 +8,8 @@ module ClaudeAgent # block.text # => "Hello, world!" # class TextBlock < ImmutableRecord + include Message + attribute :text def type diff --git a/lib/claude_agent/content_blocks/thinking_block.rb b/lib/claude_agent/content_blocks/thinking_block.rb index fa4c3ae..0227b94 100644 --- a/lib/claude_agent/content_blocks/thinking_block.rb +++ b/lib/claude_agent/content_blocks/thinking_block.rb @@ -8,6 +8,8 @@ module ClaudeAgent # block.thinking # => "Let me consider..." # class ThinkingBlock < ImmutableRecord + include Message + attribute :thinking attribute :signature diff --git a/lib/claude_agent/content_blocks/tool_result_block.rb b/lib/claude_agent/content_blocks/tool_result_block.rb index e5574d5..dfb0aaa 100644 --- a/lib/claude_agent/content_blocks/tool_result_block.rb +++ b/lib/claude_agent/content_blocks/tool_result_block.rb @@ -7,6 +7,8 @@ module ClaudeAgent # block = ToolResultBlock.new(tool_use_id: "tool_123", content: "file contents", is_error: false) # class ToolResultBlock < ImmutableRecord + include Message + attribute :tool_use_id attribute :content, default: nil attribute :is_error, default: nil diff --git a/lib/claude_agent/content_blocks/tool_use_block.rb b/lib/claude_agent/content_blocks/tool_use_block.rb index c1f7916..97e47d8 100644 --- a/lib/claude_agent/content_blocks/tool_use_block.rb +++ b/lib/claude_agent/content_blocks/tool_use_block.rb @@ -11,6 +11,8 @@ module ClaudeAgent # block.name # => "Read" # class ToolUseBlock < ImmutableRecord + include Message + attribute :id attribute :name attribute :input diff --git a/lib/claude_agent/control_protocol.rb b/lib/claude_agent/control_protocol.rb index 64fb77e..16d6dbb 100644 --- a/lib/claude_agent/control_protocol.rb +++ b/lib/claude_agent/control_protocol.rb @@ -3,12 +3,6 @@ require "json" require "securerandom" -require_relative "control_protocol/primitives" -require_relative "control_protocol/lifecycle" -require_relative "control_protocol/messaging" -require_relative "control_protocol/commands" -require_relative "control_protocol/request_handling" - module ClaudeAgent # Handles the control protocol for bidirectional communication with Claude Code CLI # diff --git a/lib/claude_agent/error.rb b/lib/claude_agent/error.rb new file mode 100644 index 0000000..8b6b58b --- /dev/null +++ b/lib/claude_agent/error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Base error class for all ClaudeAgent errors + class Error < StandardError; end +end diff --git a/lib/claude_agent/errors.rb b/lib/claude_agent/errors.rb deleted file mode 100644 index d743a6d..0000000 --- a/lib/claude_agent/errors.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Base error class for all ClaudeAgent errors - class Error < StandardError; end - - # Raised when the Claude Code CLI cannot be found - class CLINotFoundError < Error - def initialize(message = "Claude Code CLI not found. Please install it first.") - super - end - end - - # Raised when the CLI version is below minimum required - class CLIVersionError < Error - MINIMUM_VERSION = "2.0.0" - - def initialize(found_version = nil) - message = if found_version - "Claude Code CLI version #{found_version} is below minimum required version #{MINIMUM_VERSION}" - else - "Could not determine Claude Code CLI version. Minimum required: #{MINIMUM_VERSION}" - end - super(message) - end - end - - # Raised when connection to CLI fails - class CLIConnectionError < Error - def initialize(message = "Failed to connect to Claude Code CLI") - super - end - end - - # Raised when the CLI process exits with an error - class ProcessError < Error - attr_reader :exit_code, :stderr - - def initialize(message = "CLI process failed", exit_code: nil, stderr: nil) - @exit_code = exit_code - @stderr = stderr - full_message = message - full_message += " (exit code: #{exit_code})" if exit_code - full_message += "\nStderr: #{stderr}" if stderr && !stderr.empty? - super(full_message) - end - end - - # Raised when JSON parsing fails - class JSONDecodeError < Error - attr_reader :raw_content - - def initialize(message = "Failed to decode JSON", raw_content: nil) - @raw_content = raw_content - full_message = message - full_message += "\nContent: #{raw_content[0..200]}..." if raw_content - super(full_message) - end - end - - # Raised when message parsing fails - class MessageParseError < Error - attr_reader :raw_message - - def initialize(message = "Failed to parse message", raw_message: nil) - @raw_message = raw_message - full_message = message - full_message += "\nRaw message: #{raw_message.inspect[0..200]}" if raw_message - super(full_message) - end - end - - # Raised when a control protocol request times out - class TimeoutError < Error - attr_reader :request_id, :timeout_seconds - - def initialize(message = "Request timed out", request_id: nil, timeout_seconds: nil) - @request_id = request_id - @timeout_seconds = timeout_seconds - full_message = message - full_message += " (request_id: #{request_id})" if request_id - full_message += " after #{timeout_seconds}s" if timeout_seconds - super(full_message) - end - end - - # Raised when an invalid option is provided - class ConfigurationError < Error; end - - # Raised when a resource is not found (Stripe convention) - class NotFoundError < Error; end - - # Raised when an operation is aborted/cancelled (TypeScript SDK parity) - # - # This error is raised when an operation is explicitly cancelled, - # such as through a user interrupt or abort signal. - # - # When raised from {Client#receive_turn} or {Conversation#say}, - # carries a partial {TurnResult} with whatever was accumulated - # before cancellation (text, tool executions, usage). - # - # @example Accessing partial results - # begin - # turn = conversation.say("Fix the bug") - # rescue ClaudeAgent::AbortError => e - # turn = e.partial_turn - # puts turn.text # accumulated text (from stream events if needed) - # puts turn.tool_uses # tools that were called before abort - # end - # - class AbortError < Error - # @return [TurnResult, nil] Partial turn accumulated before abort - attr_reader :partial_turn - - def initialize(message = "Operation was aborted", partial_turn: nil) - @partial_turn = partial_turn - super(message) - end - end -end diff --git a/lib/claude_agent/json_decode_error.rb b/lib/claude_agent/json_decode_error.rb new file mode 100644 index 0000000..477dcc8 --- /dev/null +++ b/lib/claude_agent/json_decode_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when JSON parsing fails + class JSONDecodeError < Error + attr_reader :raw_content + + def initialize(message = "Failed to decode JSON", raw_content: nil) + @raw_content = raw_content + full_message = message + full_message += "\nContent: #{raw_content[0..200]}..." if raw_content + super(full_message) + end + end +end diff --git a/lib/claude_agent/local_spawned_process.rb b/lib/claude_agent/local_spawned_process.rb new file mode 100644 index 0000000..dce485c --- /dev/null +++ b/lib/claude_agent/local_spawned_process.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "open3" + +module ClaudeAgent + # Local spawned process wrapping Open3.popen3 (TypeScript SDK parity) + # + # This is the default implementation used when no custom spawn function is provided. + # + # @example + # process = LocalSpawnedProcess.spawn(options) + # process.write('{"type":"user"}\n') + # process.read_stdout { |line| puts line } + # process.close + # + class LocalSpawnedProcess + include SpawnedProcess + + attr_reader :pid, :stdin, :stdout, :stderr, :wait_thread + + # Spawn a new local process + # @param spawn_options [SpawnOptions] Options for spawning + # @return [LocalSpawnedProcess] + def self.spawn(spawn_options) + cmd = spawn_options.to_command_array + env = spawn_options.env || {} + cwd = spawn_options.cwd + + opts = {} + opts[:chdir] = cwd if cwd && Dir.exist?(cwd) + + stdin, stdout, stderr, wait_thread = Open3.popen3(env, *cmd, **opts) + + new(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread) + end + + def initialize(stdin:, stdout:, stderr:, wait_thread:) + @stdin = stdin + @stdout = stdout + @stderr = stderr + @wait_thread = wait_thread + @killed = false + @mutex = Mutex.new + end + + def write(data) + @mutex.synchronize do + return if @stdin.closed? + + @stdin.write(data) + @stdin.write("\n") unless data.end_with?("\n") + @stdin.flush + end + rescue Errno::EPIPE + # Process terminated + end + + def read_stdout(&block) + return enum_for(:read_stdout) unless block_given? + + @stdout.each_line(&block) + rescue IOError + # Stream closed + end + + def read_stderr(&block) + return enum_for(:read_stderr) unless block_given? + + @stderr.each_line(&block) + rescue IOError + # Stream closed + end + + def close_stdin + @mutex.synchronize do + @stdin.close unless @stdin.closed? + end + end + + def terminate(timeout: 5) + return unless running? + + pid = @wait_thread.pid + begin + Process.kill("TERM", pid) + rescue Errno::ESRCH, Errno::EPERM + return + end + + unless @wait_thread.join(timeout) + kill + end + end + + def kill + return unless running? + + @mutex.synchronize { @killed = true } + pid = @wait_thread.pid + begin + Process.kill("KILL", pid) + rescue Errno::ESRCH, Errno::EPERM + # Already dead + end + end + + def running? + @wait_thread.alive? + end + + def exit_status + @wait_thread.value&.exitstatus + end + + def killed? + @killed + end + + def close + @mutex.synchronize do + @stdin.close unless @stdin.closed? + @stdout.close unless @stdout.closed? + @stderr.close unless @stderr.closed? + end + @wait_thread.value + end + end +end diff --git a/lib/claude_agent/logging.rb b/lib/claude_agent/logging.rb deleted file mode 100644 index d5244c8..0000000 --- a/lib/claude_agent/logging.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -require "logger" - -module ClaudeAgent - # Null logger that discards all output with zero overhead. - # - # All log methods return +true+ immediately without performing any I/O. - # This is the default logger, ensuring logging adds no cost when not configured. - # - # @example - # logger = ClaudeAgent::NullLogger.new - # logger.info("transport") { "This is discarded" } # => true - # - class NullLogger < Logger - def initialize - super(File::NULL) - @level = Logger::DEBUG - end - - def add(_severity = nil, _message = nil, _progname = nil) - true - end - - def debug(...) = true - def info(...) = true - def warn(...) = true - def error(...) = true - def fatal(...) = true - def unknown(...) = true - - def debug? = false - def info? = false - def warn? = false - def error? = false - def fatal? = false - end - - # Compact log formatter with gem name tag. - # - # Output format: - # [ClaudeAgent] [12:00:00.123] DEBUG -- transport: Spawning CLI - # - LOG_FORMATTER = proc do |severity, time, progname, msg| - "[ClaudeAgent] [#{time.strftime("%H:%M:%S.%L")}] #{severity.ljust(5)} -- #{progname}: #{msg}\n" - end - - class << self - # Module-level logger used by all components unless overridden per-query. - # - # Defaults to {NullLogger} for zero overhead. Set to any +Logger+-compatible - # instance to enable logging. - # - # @return [Logger] - # - # @example - # ClaudeAgent.logger = Logger.new($stderr, level: :info) - # - def logger - @logger ||= default_logger - end - - # Set the module-level logger. - # - # @param logger [Logger] A Logger-compatible instance - # @return [Logger] - def logger=(logger) - @logger = logger - end - - # Enable debug-level logging to stderr (or a custom output). - # - # Convenience method for quick debugging. Creates a +Logger+ with - # a compact formatter and DEBUG level. - # - # @param output [IO] Output destination (default: +$stderr+) - # @return [Logger] The configured logger - # - # @example - # ClaudeAgent.debug! - # ClaudeAgent.debug!(output: $stdout) - # ClaudeAgent.debug!(output: File.open("debug.log", "a")) - # - def debug!(output: $stderr) - self.logger = Logger.new(output, level: Logger::DEBUG).tap do |l| - l.formatter = LOG_FORMATTER - end - end - - private - - def default_logger - if ENV["CLAUDE_AGENT_DEBUG"] - Logger.new($stderr, level: Logger::DEBUG).tap do |l| - l.formatter = LOG_FORMATTER - end - else - NullLogger.new - end - end - end -end diff --git a/lib/claude_agent/message.rb b/lib/claude_agent/message.rb index d92953d..419f73a 100644 --- a/lib/claude_agent/message.rb +++ b/lib/claude_agent/message.rb @@ -83,10 +83,4 @@ def deconstruct_keys(keys) end end end - - # Include Message in all message types - MESSAGE_TYPES.each { |klass| klass.include(Message) } - - # Include Message in all content block types - CONTENT_BLOCK_TYPES.each { |klass| klass.include(Message) } end diff --git a/lib/claude_agent/message_parse_error.rb b/lib/claude_agent/message_parse_error.rb new file mode 100644 index 0000000..6a355ec --- /dev/null +++ b/lib/claude_agent/message_parse_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when message parsing fails + class MessageParseError < Error + attr_reader :raw_message + + def initialize(message = "Failed to parse message", raw_message: nil) + @raw_message = raw_message + full_message = message + full_message += "\nRaw message: #{raw_message.inspect[0..200]}" if raw_message + super(full_message) + end + end +end diff --git a/lib/claude_agent/messages.rb b/lib/claude_agent/messages.rb deleted file mode 100644 index 76d6442..0000000 --- a/lib/claude_agent/messages.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require_relative "messages/conversation" -require_relative "messages/result" -require_relative "messages/system" -require_relative "messages/streaming" -require_relative "messages/tool_lifecycle" -require_relative "messages/hook_lifecycle" -require_relative "messages/task_lifecycle" -require_relative "messages/generic" - -module ClaudeAgent - # All message types - MESSAGE_TYPES = [ - UserMessage, - UserMessageReplay, - AssistantMessage, - SystemMessage, - ResultMessage, - StreamEvent, - CompactBoundaryMessage, - StatusMessage, - ToolProgressMessage, - HookResponseMessage, - AuthStatusMessage, - TaskNotificationMessage, - HookStartedMessage, - HookProgressMessage, - ToolUseSummaryMessage, - FilesPersistedEvent, - TaskStartedMessage, - TaskProgressMessage, - RateLimitEvent, - PromptSuggestionMessage, - ElicitationCompleteMessage, - LocalCommandOutputMessage, - APIRetryMessage, - GenericMessage - ].freeze -end diff --git a/lib/claude_agent/messages/api_retry_message.rb b/lib/claude_agent/messages/api_retry_message.rb new file mode 100644 index 0000000..72e1909 --- /dev/null +++ b/lib/claude_agent/messages/api_retry_message.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ClaudeAgent + # API retry message (TypeScript SDK v0.2.77 parity) + # + # Emitted when an API request fails with a retryable error and will be + # retried after a delay. Exposes attempt count, max retries, delay, and + # error status for observability. + # + # @example + # msg = APIRetryMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # attempt: 1, + # max_retries: 3, + # retry_delay_ms: 5000, + # error_status: 529, + # error: "rate_limit" + # ) + # + class APIRetryMessage < ImmutableRecord + include Message + + attribute :uuid, default: "" + attribute :session_id, default: "" + attribute :attempt, default: 0 + attribute :max_retries, default: 0 + attribute :retry_delay_ms, default: 0 + attribute :error_status, default: nil + attribute :error, default: nil + + def type + :api_retry + end + end +end diff --git a/lib/claude_agent/messages/assistant_message.rb b/lib/claude_agent/messages/assistant_message.rb new file mode 100644 index 0000000..7ad0dc5 --- /dev/null +++ b/lib/claude_agent/messages/assistant_message.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Assistant message from Claude + # + # @example + # msg = AssistantMessage.new( + # content: [TextBlock.new(text: "Hello!")], + # model: "claude-sonnet-4-5-20250514", + # uuid: "msg-123", + # session_id: "session-abc" + # ) + # + class AssistantMessage < ImmutableRecord + include Message + + attribute :content + attribute :model + attribute :uuid, default: nil + attribute :session_id, default: nil + attribute :error, default: nil + attribute :parent_tool_use_id, default: nil + + def type + :assistant + end + + # Get all text content concatenated + # @return [String] + def text + content + .select { |block| block.is_a?(TextBlock) } + .map(&:text) + .join + end + + # Get all thinking content concatenated + # @return [String] + def thinking + content + .select { |block| block.is_a?(ThinkingBlock) } + .map(&:thinking) + .join + end + + # Get all tool use blocks + # @return [Array] + def tool_uses + content.select { |block| block.is_a?(ToolUseBlock) } + end + + # Check if assistant wants to use a tool + # @return [Boolean] + def has_tool_use? + content.any? { |block| block.is_a?(ToolUseBlock) } + end + end +end diff --git a/lib/claude_agent/messages/auth_status_message.rb b/lib/claude_agent/messages/auth_status_message.rb new file mode 100644 index 0000000..f64d424 --- /dev/null +++ b/lib/claude_agent/messages/auth_status_message.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Auth status message (TypeScript SDK parity) + # + # Reports authentication status during login flows. + # + # @example + # msg = AuthStatusMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # is_authenticating: true, + # output: ["Waiting for browser..."] + # ) + # + class AuthStatusMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :is_authenticating + attribute :output, default: [] + attribute :error, default: nil + + def type + :auth_status + end + end +end diff --git a/lib/claude_agent/messages/compact_boundary_message.rb b/lib/claude_agent/messages/compact_boundary_message.rb new file mode 100644 index 0000000..56968d1 --- /dev/null +++ b/lib/claude_agent/messages/compact_boundary_message.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Compact boundary message (conversation compaction marker) - TypeScript SDK parity + # + # Sent when the conversation is compacted to reduce context size. + # Contains metadata about the compaction operation. + # + # @example + # msg = CompactBoundaryMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # compact_metadata: { trigger: "auto", pre_tokens: 50000 } + # ) + # msg.trigger # => "auto" + # msg.pre_tokens # => 50000 + # + class CompactBoundaryMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :compact_metadata + + def type + :compact_boundary + end + + # Get the compaction trigger type + # @return [String] "manual" or "auto" + def trigger + compact_metadata[:trigger] + end + + # Get the token count before compaction + # @return [Integer, nil] + def pre_tokens + compact_metadata[:pre_tokens] + end + end +end diff --git a/lib/claude_agent/messages/conversation.rb b/lib/claude_agent/messages/conversation.rb deleted file mode 100644 index e7599ae..0000000 --- a/lib/claude_agent/messages/conversation.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # User message sent to Claude - # - # @example - # msg = UserMessage.new(content: "Hello!", uuid: "abc-123", session_id: "session-abc") - # - class UserMessage < ImmutableRecord - attribute :content - attribute :uuid, default: nil - attribute :session_id, default: nil - attribute :parent_tool_use_id, default: nil - - def type - :user - end - - # Get text content if content is a string - # @return [String, nil] - def text - content.is_a?(String) ? content : nil - end - - # Check if this is a replayed message - # @return [Boolean] - def replay? - false - end - end - - # User message replay (TypeScript SDK parity) - # - # Sent when resuming a session with existing conversation history. - # These messages represent replayed user messages from a previous session. - # - # @example - # msg = UserMessageReplay.new( - # content: "Hello!", - # uuid: "abc-123", - # session_id: "session-abc", - # is_replay: true - # ) - # msg.replay? # => true - # - class UserMessageReplay < ImmutableRecord - attribute :content - attribute :uuid, default: nil - attribute :session_id, default: nil - attribute :parent_tool_use_id, default: nil - attribute :is_replay, default: true - attribute :is_synthetic, default: nil - attribute :tool_use_result, default: nil - - def type - :user - end - - # Get text content if content is a string - # @return [String, nil] - def text - content.is_a?(String) ? content : nil - end - - # Check if this is a replayed message - # @return [Boolean] - def replay? - is_replay == true - end - - # Check if this is a synthetic message (system-generated) - # @return [Boolean] - def synthetic? - is_synthetic == true - end - end - - # Assistant message from Claude - # - # @example - # msg = AssistantMessage.new( - # content: [TextBlock.new(text: "Hello!")], - # model: "claude-sonnet-4-5-20250514", - # uuid: "msg-123", - # session_id: "session-abc" - # ) - # - class AssistantMessage < ImmutableRecord - attribute :content - attribute :model - attribute :uuid, default: nil - attribute :session_id, default: nil - attribute :error, default: nil - attribute :parent_tool_use_id, default: nil - - def type - :assistant - end - - # Get all text content concatenated - # @return [String] - def text - content - .select { |block| block.is_a?(TextBlock) } - .map(&:text) - .join - end - - # Get all thinking content concatenated - # @return [String] - def thinking - content - .select { |block| block.is_a?(ThinkingBlock) } - .map(&:thinking) - .join - end - - # Get all tool use blocks - # @return [Array] - def tool_uses - content.select { |block| block.is_a?(ToolUseBlock) } - end - - # Check if assistant wants to use a tool - # @return [Boolean] - def has_tool_use? - content.any? { |block| block.is_a?(ToolUseBlock) } - end - end -end diff --git a/lib/claude_agent/messages/elicitation_complete_message.rb b/lib/claude_agent/messages/elicitation_complete_message.rb new file mode 100644 index 0000000..543dd2e --- /dev/null +++ b/lib/claude_agent/messages/elicitation_complete_message.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Elicitation complete message (TypeScript SDK v0.2.63 parity) + # + # Sent when an MCP server elicitation request completes. + # + # @example + # msg = ElicitationCompleteMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # mcp_server_name: "my-server", + # elicitation_id: "elic-456" + # ) + # + class ElicitationCompleteMessage < ImmutableRecord + include Message + + attribute :uuid, default: "" + attribute :session_id, default: "" + attribute :mcp_server_name, default: "" + attribute :elicitation_id, default: "" + + def type + :elicitation_complete + end + end +end diff --git a/lib/claude_agent/messages/files_persisted_event.rb b/lib/claude_agent/messages/files_persisted_event.rb new file mode 100644 index 0000000..74dd594 --- /dev/null +++ b/lib/claude_agent/messages/files_persisted_event.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Files persisted event (TypeScript SDK v0.2.25 parity) + # + # Sent when files are persisted to storage during a session. + # Contains lists of successfully persisted files and any failures. + # + # @example + # msg = FilesPersistedEvent.new( + # uuid: "msg-123", + # session_id: "session-abc", + # files: [{ filename: "test.rb", file_id: "file-456" }], + # failed: [], + # processed_at: "2026-01-30T12:00:00Z" + # ) + # msg.files.first[:filename] # => "test.rb" + # + class FilesPersistedEvent < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :files, default: [] + attribute :failed, default: [] + attribute :processed_at, default: nil + + def type + :files_persisted + end + end +end diff --git a/lib/claude_agent/messages/generic.rb b/lib/claude_agent/messages/generic_message.rb similarity index 97% rename from lib/claude_agent/messages/generic.rb rename to lib/claude_agent/messages/generic_message.rb index 320b7e5..1b413e0 100644 --- a/lib/claude_agent/messages/generic.rb +++ b/lib/claude_agent/messages/generic_message.rb @@ -15,6 +15,8 @@ module ClaudeAgent # msg.to_h # => { data: "hello" } # class GenericMessage < ImmutableRecord + include Message + attribute :message_type attribute :raw diff --git a/lib/claude_agent/messages/hook_progress_message.rb b/lib/claude_agent/messages/hook_progress_message.rb new file mode 100644 index 0000000..2aca260 --- /dev/null +++ b/lib/claude_agent/messages/hook_progress_message.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Hook progress message (TypeScript SDK parity) + # + # Reports progress during hook execution. + # + # @example + # msg = HookProgressMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # hook_id: "hook-456", + # hook_name: "my-hook", + # hook_event: "PreToolUse", + # stdout: "Hook output so far...", + # stderr: "", + # output: "Combined output" + # ) + # + class HookProgressMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :hook_id + attribute :hook_name + attribute :hook_event + attribute :stdout, default: "" + attribute :stderr, default: "" + attribute :output, default: "" + + def type + :hook_progress + end + end +end diff --git a/lib/claude_agent/messages/hook_lifecycle.rb b/lib/claude_agent/messages/hook_response_message.rb similarity index 54% rename from lib/claude_agent/messages/hook_lifecycle.rb rename to lib/claude_agent/messages/hook_response_message.rb index c68f448..10dd8e3 100644 --- a/lib/claude_agent/messages/hook_lifecycle.rb +++ b/lib/claude_agent/messages/hook_response_message.rb @@ -1,62 +1,6 @@ # frozen_string_literal: true module ClaudeAgent - # Hook started message (TypeScript SDK parity) - # - # Sent when a hook execution starts. - # - # @example - # msg = HookStartedMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # hook_id: "hook-456", - # hook_name: "my-hook", - # hook_event: "PreToolUse" - # ) - # - class HookStartedMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :hook_id - attribute :hook_name - attribute :hook_event - - def type - :hook_started - end - end - - # Hook progress message (TypeScript SDK parity) - # - # Reports progress during hook execution. - # - # @example - # msg = HookProgressMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # hook_id: "hook-456", - # hook_name: "my-hook", - # hook_event: "PreToolUse", - # stdout: "Hook output so far...", - # stderr: "", - # output: "Combined output" - # ) - # - class HookProgressMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :hook_id - attribute :hook_name - attribute :hook_event - attribute :stdout, default: "" - attribute :stderr, default: "" - attribute :output, default: "" - - def type - :hook_progress - end - end - # Hook response message (TypeScript SDK parity) # # Contains output from hook executions. @@ -84,6 +28,8 @@ def type # - "cancelled" - Hook was cancelled # class HookResponseMessage < ImmutableRecord + include Message + attribute :uuid attribute :session_id attribute :hook_name diff --git a/lib/claude_agent/messages/hook_started_message.rb b/lib/claude_agent/messages/hook_started_message.rb new file mode 100644 index 0000000..f35eae3 --- /dev/null +++ b/lib/claude_agent/messages/hook_started_message.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Hook started message (TypeScript SDK parity) + # + # Sent when a hook execution starts. + # + # @example + # msg = HookStartedMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # hook_id: "hook-456", + # hook_name: "my-hook", + # hook_event: "PreToolUse" + # ) + # + class HookStartedMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :hook_id + attribute :hook_name + attribute :hook_event + + def type + :hook_started + end + end +end diff --git a/lib/claude_agent/messages/local_command_output_message.rb b/lib/claude_agent/messages/local_command_output_message.rb new file mode 100644 index 0000000..ecabe49 --- /dev/null +++ b/lib/claude_agent/messages/local_command_output_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Local command output message (TypeScript SDK v0.2.63 parity) + # + # Contains output from a local command execution. + # + # @example + # msg = LocalCommandOutputMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # content: "command output here" + # ) + # + class LocalCommandOutputMessage < ImmutableRecord + include Message + + attribute :uuid, default: "" + attribute :session_id, default: "" + attribute :content, default: "" + + def type + :local_command_output + end + end +end diff --git a/lib/claude_agent/messages/prompt_suggestion_message.rb b/lib/claude_agent/messages/prompt_suggestion_message.rb new file mode 100644 index 0000000..861f17a --- /dev/null +++ b/lib/claude_agent/messages/prompt_suggestion_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Prompt suggestion message (TypeScript SDK v0.2.47 parity) + # + # Contains a suggested prompt for the user. + # + # @example + # msg = PromptSuggestionMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # suggestion: "Tell me about this project" + # ) + # + class PromptSuggestionMessage < ImmutableRecord + include Message + + attribute :suggestion + attribute :uuid, default: nil + attribute :session_id, default: nil + + def type + :prompt_suggestion + end + end +end diff --git a/lib/claude_agent/messages/rate_limit_event.rb b/lib/claude_agent/messages/rate_limit_event.rb new file mode 100644 index 0000000..0403cc0 --- /dev/null +++ b/lib/claude_agent/messages/rate_limit_event.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Rate limit event (TypeScript SDK v0.2.45 parity) + # + # Reports rate limit status and utilization information. + # + # @example + # msg = RateLimitEvent.new( + # uuid: "msg-123", + # session_id: "session-abc", + # rate_limit_info: { + # status: "allowed_warning", + # resetsAt: 1700000000, + # rateLimitType: "five_hour", + # utilization: 0.85, + # isUsingOverage: false, + # overageStatus: "available" + # } + # ) + # msg.status # => "allowed_warning" + # + class RateLimitEvent < ImmutableRecord + include Message + + attribute :rate_limit_info + attribute :uuid, default: nil + attribute :session_id, default: nil + + def type + :rate_limit_event + end + + # Get the rate limit status + # @return [String, nil] + def status + rate_limit_info[:status] + end + end +end diff --git a/lib/claude_agent/messages/result.rb b/lib/claude_agent/messages/result_message.rb similarity index 98% rename from lib/claude_agent/messages/result.rb rename to lib/claude_agent/messages/result_message.rb index 0b38053..8045488 100644 --- a/lib/claude_agent/messages/result.rb +++ b/lib/claude_agent/messages/result_message.rb @@ -23,6 +23,8 @@ module ClaudeAgent # ) # class ResultMessage < ImmutableRecord + include Message + attribute :subtype attribute :duration_ms attribute :duration_api_ms diff --git a/lib/claude_agent/messages/status_message.rb b/lib/claude_agent/messages/status_message.rb new file mode 100644 index 0000000..73fbe2a --- /dev/null +++ b/lib/claude_agent/messages/status_message.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Status message (TypeScript SDK parity) + # + # Reports session status like 'compacting' during operations. + # + # @example + # msg = StatusMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # status: "compacting" + # ) + # + class StatusMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :status + attribute :permission_mode, default: nil + + def type + :status + end + end +end diff --git a/lib/claude_agent/messages/streaming.rb b/lib/claude_agent/messages/stream_event.rb similarity index 58% rename from lib/claude_agent/messages/streaming.rb rename to lib/claude_agent/messages/stream_event.rb index 51ce9be..a2a04f3 100644 --- a/lib/claude_agent/messages/streaming.rb +++ b/lib/claude_agent/messages/stream_event.rb @@ -11,6 +11,8 @@ module ClaudeAgent # ) # class StreamEvent < ImmutableRecord + include Message + attribute :uuid attribute :session_id attribute :event @@ -63,60 +65,4 @@ def content_index event[:index] || event["index"] end end - - # Rate limit event (TypeScript SDK v0.2.45 parity) - # - # Reports rate limit status and utilization information. - # - # @example - # msg = RateLimitEvent.new( - # uuid: "msg-123", - # session_id: "session-abc", - # rate_limit_info: { - # status: "allowed_warning", - # resetsAt: 1700000000, - # rateLimitType: "five_hour", - # utilization: 0.85, - # isUsingOverage: false, - # overageStatus: "available" - # } - # ) - # msg.status # => "allowed_warning" - # - class RateLimitEvent < ImmutableRecord - attribute :rate_limit_info - attribute :uuid, default: nil - attribute :session_id, default: nil - - def type - :rate_limit_event - end - - # Get the rate limit status - # @return [String, nil] - def status - rate_limit_info[:status] - end - end - - # Prompt suggestion message (TypeScript SDK v0.2.47 parity) - # - # Contains a suggested prompt for the user. - # - # @example - # msg = PromptSuggestionMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # suggestion: "Tell me about this project" - # ) - # - class PromptSuggestionMessage < ImmutableRecord - attribute :suggestion - attribute :uuid, default: nil - attribute :session_id, default: nil - - def type - :prompt_suggestion - end - end end diff --git a/lib/claude_agent/messages/system.rb b/lib/claude_agent/messages/system.rb deleted file mode 100644 index 2ee7275..0000000 --- a/lib/claude_agent/messages/system.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # System message (internal events) - # - # @example - # msg = SystemMessage.new(subtype: "init", data: {version: "2.0.0"}) - # - class SystemMessage < ImmutableRecord - attribute :subtype - attribute :data - - def type - :system - end - end - - # Compact boundary message (conversation compaction marker) - TypeScript SDK parity - # - # Sent when the conversation is compacted to reduce context size. - # Contains metadata about the compaction operation. - # - # @example - # msg = CompactBoundaryMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # compact_metadata: { trigger: "auto", pre_tokens: 50000 } - # ) - # msg.trigger # => "auto" - # msg.pre_tokens # => 50000 - # - class CompactBoundaryMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :compact_metadata - - def type - :compact_boundary - end - - # Get the compaction trigger type - # @return [String] "manual" or "auto" - def trigger - compact_metadata[:trigger] - end - - # Get the token count before compaction - # @return [Integer, nil] - def pre_tokens - compact_metadata[:pre_tokens] - end - end - - # Status message (TypeScript SDK parity) - # - # Reports session status like 'compacting' during operations. - # - # @example - # msg = StatusMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # status: "compacting" - # ) - # - # API retry message (TypeScript SDK v0.2.77 parity) - # - # Emitted when an API request fails with a retryable error and will be - # retried after a delay. Exposes attempt count, max retries, delay, and - # error status for observability. - # - # @example - # msg = APIRetryMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # attempt: 1, - # max_retries: 3, - # retry_delay_ms: 5000, - # error_status: 529, - # error: "rate_limit" - # ) - # - class APIRetryMessage < ImmutableRecord - attribute :uuid, default: "" - attribute :session_id, default: "" - attribute :attempt, default: 0 - attribute :max_retries, default: 0 - attribute :retry_delay_ms, default: 0 - attribute :error_status, default: nil - attribute :error, default: nil - - def type - :api_retry - end - end - - class StatusMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :status - attribute :permission_mode, default: nil - - def type - :status - end - end -end diff --git a/lib/claude_agent/messages/system_message.rb b/lib/claude_agent/messages/system_message.rb new file mode 100644 index 0000000..6d92045 --- /dev/null +++ b/lib/claude_agent/messages/system_message.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ClaudeAgent + # System message (internal events) + # + # @example + # msg = SystemMessage.new(subtype: "init", data: {version: "2.0.0"}) + # + class SystemMessage < ImmutableRecord + include Message + + attribute :subtype + attribute :data + + def type + :system + end + end +end diff --git a/lib/claude_agent/messages/task_lifecycle.rb b/lib/claude_agent/messages/task_lifecycle.rb deleted file mode 100644 index 649f662..0000000 --- a/lib/claude_agent/messages/task_lifecycle.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Task started message (TypeScript SDK v0.2.45 parity) - # - # Sent when a new task (subagent) is started. - # - # @example - # msg = TaskStartedMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # task_id: "task-456", - # tool_use_id: "tool-789", - # description: "Running tests", - # task_type: "bash" - # ) - # - class TaskStartedMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :task_id - attribute :tool_use_id, default: nil - attribute :description, default: nil - attribute :task_type, default: nil - attribute :prompt, default: nil - - def type - :task_started - end - end - - # Task progress message (TypeScript SDK v0.2.51 parity) - # - # Reports progress during background task (subagent) execution. - # Contains usage information and description of what the task is doing. - # - # @example - # msg = TaskProgressMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # task_id: "task-456", - # description: "Searching codebase for patterns", - # usage: { total_tokens: 5000, tool_uses: 3, duration_ms: 2500 } - # ) - # - class TaskProgressMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :task_id - attribute :description - attribute :tool_use_id, default: nil - attribute :usage, default: nil - attribute :last_tool_name, default: nil - attribute :summary, default: nil - - def type - :task_progress - end - end - - # Task notification message (TypeScript SDK parity) - # - # Sent when a background task completes, fails, or is stopped. - # Used for tracking async task execution status. - # - # @example - # msg = TaskNotificationMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # task_id: "task-456", - # status: "completed", - # output_file: "/path/to/output.txt", - # summary: "Task completed successfully" - # ) - # msg.completed? # => true - # msg.failed? # => false - # - # Status values: - # - "completed" - Task finished successfully - # - "failed" - Task encountered an error - # - "stopped" - Task was manually stopped - # - class TaskNotificationMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :task_id - attribute :status - attribute :output_file - attribute :summary - attribute :tool_use_id, default: nil - attribute :usage, default: nil - - def type - :task_notification - end - - # Check if task completed successfully - # @return [Boolean] - def completed? - status == "completed" - end - - # Check if task failed - # @return [Boolean] - def failed? - status == "failed" - end - - # Check if task was stopped - # @return [Boolean] - def stopped? - status == "stopped" - end - end - - # Files persisted event (TypeScript SDK v0.2.25 parity) - # - # Sent when files are persisted to storage during a session. - # Contains lists of successfully persisted files and any failures. - # - # @example - # msg = FilesPersistedEvent.new( - # uuid: "msg-123", - # session_id: "session-abc", - # files: [{ filename: "test.rb", file_id: "file-456" }], - # failed: [], - # processed_at: "2026-01-30T12:00:00Z" - # ) - # msg.files.first[:filename] # => "test.rb" - # - class FilesPersistedEvent < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :files, default: [] - attribute :failed, default: [] - attribute :processed_at, default: nil - - def type - :files_persisted - end - end - - # Elicitation complete message (TypeScript SDK v0.2.63 parity) - # - # Sent when an MCP server elicitation request completes. - # - # @example - # msg = ElicitationCompleteMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # mcp_server_name: "my-server", - # elicitation_id: "elic-456" - # ) - # - class ElicitationCompleteMessage < ImmutableRecord - attribute :uuid, default: "" - attribute :session_id, default: "" - attribute :mcp_server_name, default: "" - attribute :elicitation_id, default: "" - - def type - :elicitation_complete - end - end - - # Auth status message (TypeScript SDK parity) - # - # Reports authentication status during login flows. - # - # @example - # msg = AuthStatusMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # is_authenticating: true, - # output: ["Waiting for browser..."] - # ) - # - class AuthStatusMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :is_authenticating - attribute :output, default: [] - attribute :error, default: nil - - def type - :auth_status - end - end -end diff --git a/lib/claude_agent/messages/task_notification_message.rb b/lib/claude_agent/messages/task_notification_message.rb new file mode 100644 index 0000000..7fbc774 --- /dev/null +++ b/lib/claude_agent/messages/task_notification_message.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Task notification message (TypeScript SDK parity) + # + # Sent when a background task completes, fails, or is stopped. + # Used for tracking async task execution status. + # + # @example + # msg = TaskNotificationMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # task_id: "task-456", + # status: "completed", + # output_file: "/path/to/output.txt", + # summary: "Task completed successfully" + # ) + # msg.completed? # => true + # msg.failed? # => false + # + # Status values: + # - "completed" - Task finished successfully + # - "failed" - Task encountered an error + # - "stopped" - Task was manually stopped + # + class TaskNotificationMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :task_id + attribute :status + attribute :output_file + attribute :summary + attribute :tool_use_id, default: nil + attribute :usage, default: nil + + def type + :task_notification + end + + # Check if task completed successfully + # @return [Boolean] + def completed? + status == "completed" + end + + # Check if task failed + # @return [Boolean] + def failed? + status == "failed" + end + + # Check if task was stopped + # @return [Boolean] + def stopped? + status == "stopped" + end + end +end diff --git a/lib/claude_agent/messages/task_progress_message.rb b/lib/claude_agent/messages/task_progress_message.rb new file mode 100644 index 0000000..6fb6d70 --- /dev/null +++ b/lib/claude_agent/messages/task_progress_message.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Task progress message (TypeScript SDK v0.2.51 parity) + # + # Reports progress during background task (subagent) execution. + # Contains usage information and description of what the task is doing. + # + # @example + # msg = TaskProgressMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # task_id: "task-456", + # description: "Searching codebase for patterns", + # usage: { total_tokens: 5000, tool_uses: 3, duration_ms: 2500 } + # ) + # + class TaskProgressMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :task_id + attribute :description + attribute :tool_use_id, default: nil + attribute :usage, default: nil + attribute :last_tool_name, default: nil + attribute :summary, default: nil + + def type + :task_progress + end + end +end diff --git a/lib/claude_agent/messages/task_started_message.rb b/lib/claude_agent/messages/task_started_message.rb new file mode 100644 index 0000000..1d627d8 --- /dev/null +++ b/lib/claude_agent/messages/task_started_message.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Task started message (TypeScript SDK v0.2.45 parity) + # + # Sent when a new task (subagent) is started. + # + # @example + # msg = TaskStartedMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # task_id: "task-456", + # tool_use_id: "tool-789", + # description: "Running tests", + # task_type: "bash" + # ) + # + class TaskStartedMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :task_id + attribute :tool_use_id, default: nil + attribute :description, default: nil + attribute :task_type, default: nil + attribute :prompt, default: nil + + def type + :task_started + end + end +end diff --git a/lib/claude_agent/messages/tool_lifecycle.rb b/lib/claude_agent/messages/tool_lifecycle.rb deleted file mode 100644 index 35bed04..0000000 --- a/lib/claude_agent/messages/tool_lifecycle.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Tool progress message (TypeScript SDK parity) - # - # Reports progress during long-running tool executions. - # - # @example - # msg = ToolProgressMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # tool_use_id: "tool-456", - # tool_name: "Bash", - # elapsed_time_seconds: 5.2 - # ) - # - class ToolProgressMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :tool_use_id - attribute :tool_name - attribute :elapsed_time_seconds - attribute :parent_tool_use_id, default: nil - attribute :task_id, default: nil - - def type - :tool_progress - end - end - - # Tool use summary message (TypeScript SDK parity) - # - # Contains a summary of tool use for collapsed display. - # - # @example - # msg = ToolUseSummaryMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # summary: "Read 3 files", - # preceding_tool_use_ids: ["tool-1", "tool-2", "tool-3"] - # ) - # - class ToolUseSummaryMessage < ImmutableRecord - attribute :uuid - attribute :session_id - attribute :summary - attribute :preceding_tool_use_ids, default: [] - - def type - :tool_use_summary - end - end - - # Local command output message (TypeScript SDK v0.2.63 parity) - # - # Contains output from a local command execution. - # - # @example - # msg = LocalCommandOutputMessage.new( - # uuid: "msg-123", - # session_id: "session-abc", - # content: "command output here" - # ) - # - class LocalCommandOutputMessage < ImmutableRecord - attribute :uuid, default: "" - attribute :session_id, default: "" - attribute :content, default: "" - - def type - :local_command_output - end - end -end diff --git a/lib/claude_agent/messages/tool_progress_message.rb b/lib/claude_agent/messages/tool_progress_message.rb new file mode 100644 index 0000000..c4c3606 --- /dev/null +++ b/lib/claude_agent/messages/tool_progress_message.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Tool progress message (TypeScript SDK parity) + # + # Reports progress during long-running tool executions. + # + # @example + # msg = ToolProgressMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # tool_use_id: "tool-456", + # tool_name: "Bash", + # elapsed_time_seconds: 5.2 + # ) + # + class ToolProgressMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :tool_use_id + attribute :tool_name + attribute :elapsed_time_seconds + attribute :parent_tool_use_id, default: nil + attribute :task_id, default: nil + + def type + :tool_progress + end + end +end diff --git a/lib/claude_agent/messages/tool_use_summary_message.rb b/lib/claude_agent/messages/tool_use_summary_message.rb new file mode 100644 index 0000000..f754fd0 --- /dev/null +++ b/lib/claude_agent/messages/tool_use_summary_message.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Tool use summary message (TypeScript SDK parity) + # + # Contains a summary of tool use for collapsed display. + # + # @example + # msg = ToolUseSummaryMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # summary: "Read 3 files", + # preceding_tool_use_ids: ["tool-1", "tool-2", "tool-3"] + # ) + # + class ToolUseSummaryMessage < ImmutableRecord + include Message + + attribute :uuid + attribute :session_id + attribute :summary + attribute :preceding_tool_use_ids, default: [] + + def type + :tool_use_summary + end + end +end diff --git a/lib/claude_agent/messages/user_message.rb b/lib/claude_agent/messages/user_message.rb new file mode 100644 index 0000000..66f5f6a --- /dev/null +++ b/lib/claude_agent/messages/user_message.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ClaudeAgent + # User message sent to Claude + # + # @example + # msg = UserMessage.new(content: "Hello!", uuid: "abc-123", session_id: "session-abc") + # + class UserMessage < ImmutableRecord + include Message + + attribute :content + attribute :uuid, default: nil + attribute :session_id, default: nil + attribute :parent_tool_use_id, default: nil + + def type + :user + end + + # Get text content if content is a string + # @return [String, nil] + def text + content.is_a?(String) ? content : nil + end + + # Check if this is a replayed message + # @return [Boolean] + def replay? + false + end + end +end diff --git a/lib/claude_agent/messages/user_message_replay.rb b/lib/claude_agent/messages/user_message_replay.rb new file mode 100644 index 0000000..a8a1bd3 --- /dev/null +++ b/lib/claude_agent/messages/user_message_replay.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module ClaudeAgent + # User message replay (TypeScript SDK parity) + # + # Sent when resuming a session with existing conversation history. + # These messages represent replayed user messages from a previous session. + # + # @example + # msg = UserMessageReplay.new( + # content: "Hello!", + # uuid: "abc-123", + # session_id: "session-abc", + # is_replay: true + # ) + # msg.replay? # => true + # + class UserMessageReplay < ImmutableRecord + include Message + + attribute :content + attribute :uuid, default: nil + attribute :session_id, default: nil + attribute :parent_tool_use_id, default: nil + attribute :is_replay, default: true + attribute :is_synthetic, default: nil + attribute :tool_use_result, default: nil + + def type + :user + end + + # Get text content if content is a string + # @return [String, nil] + def text + content.is_a?(String) ? content : nil + end + + # Check if this is a replayed message + # @return [Boolean] + def replay? + is_replay == true + end + + # Check if this is a synthetic message (system-generated) + # @return [Boolean] + def synthetic? + is_synthetic == true + end + end +end diff --git a/lib/claude_agent/not_found_error.rb b/lib/claude_agent/not_found_error.rb new file mode 100644 index 0000000..c3513a9 --- /dev/null +++ b/lib/claude_agent/not_found_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when a resource is not found (Stripe convention) + class NotFoundError < Error; end +end diff --git a/lib/claude_agent/null_logger.rb b/lib/claude_agent/null_logger.rb new file mode 100644 index 0000000..e78f252 --- /dev/null +++ b/lib/claude_agent/null_logger.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "logger" + +module ClaudeAgent + # Null logger that discards all output with zero overhead. + # + # All log methods return +true+ immediately without performing any I/O. + # This is the default logger, ensuring logging adds no cost when not configured. + # + # @example + # logger = ClaudeAgent::NullLogger.new + # logger.info("transport") { "This is discarded" } # => true + # + class NullLogger < Logger + def initialize + super(File::NULL) + @level = Logger::DEBUG + end + + def add(_severity = nil, _message = nil, _progname = nil) + true + end + + def debug(...) = true + def info(...) = true + def warn(...) = true + def error(...) = true + def fatal(...) = true + def unknown(...) = true + + def debug? = false + def info? = false + def warn? = false + def error? = false + def fatal? = false + end +end diff --git a/lib/claude_agent/options.rb b/lib/claude_agent/options.rb index 2d092a1..72eef59 100644 --- a/lib/claude_agent/options.rb +++ b/lib/claude_agent/options.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "options/serializer" - module ClaudeAgent # Permission modes for tool execution (TypeScript SDK parity) PERMISSION_MODES = %w[default acceptEdits plan bypassPermissions dontAsk].freeze diff --git a/lib/claude_agent/permission_result_allow.rb b/lib/claude_agent/permission_result_allow.rb new file mode 100644 index 0000000..70b3832 --- /dev/null +++ b/lib/claude_agent/permission_result_allow.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Result of a permission check (allow) + # + # @example Allow with modified input + # PermissionResultAllow.new( + # updated_input: input.merge("safe" => true), + # tool_use_id: "tool_123" + # ) + # + class PermissionResultAllow < ImmutableRecord + attribute :updated_input, default: nil + attribute :updated_permissions, default: nil + attribute :tool_use_id, default: nil + + def behavior + "allow" + end + + def to_h + h = { behavior: "allow" } + h[:updatedInput] = updated_input if updated_input + h[:updatedPermissions] = updated_permissions&.map(&:to_h) if updated_permissions + h[:toolUseID] = tool_use_id if tool_use_id + h + end + end +end diff --git a/lib/claude_agent/permission_result_deny.rb b/lib/claude_agent/permission_result_deny.rb new file mode 100644 index 0000000..d523d43 --- /dev/null +++ b/lib/claude_agent/permission_result_deny.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Result of a permission check (deny) + # + # @example Deny with message + # PermissionResultDeny.new( + # message: "Operation not allowed", + # interrupt: true, + # tool_use_id: "tool_123" + # ) + # + class PermissionResultDeny < ImmutableRecord + attribute :message, default: "" + attribute :interrupt, default: false + attribute :tool_use_id, default: nil + + def behavior + "deny" + end + + def to_h + h = { behavior: "deny", message: message, interrupt: interrupt } + h[:toolUseID] = tool_use_id if tool_use_id + h + end + end +end diff --git a/lib/claude_agent/permission_rule_value.rb b/lib/claude_agent/permission_rule_value.rb new file mode 100644 index 0000000..c407a34 --- /dev/null +++ b/lib/claude_agent/permission_rule_value.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Permission rule value (TypeScript SDK parity) + # Note: behavior is on PermissionUpdate, not on individual rules + # + class PermissionRuleValue < ImmutableRecord + attribute :tool_name, default: nil + attribute :rule_content, default: nil + + def to_h + { + toolName: tool_name, + ruleContent: rule_content + }.compact + end + end +end diff --git a/lib/claude_agent/permission_update.rb b/lib/claude_agent/permission_update.rb new file mode 100644 index 0000000..b10e7a3 --- /dev/null +++ b/lib/claude_agent/permission_update.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Permission update request + # + # @example Add rules + # PermissionUpdate.new( + # type: "addRules", + # rules: [{tool_name: "Read", behavior: "allow"}] + # ) + # + class PermissionUpdate < ImmutableRecord + attribute :type + attribute :rules, default: nil + attribute :behavior, default: nil + attribute :mode, default: nil + attribute :directories, default: nil + attribute :destination, default: nil + + def to_h + h = { type: type } + h[:rules] = rules.map { |r| normalize_rule(r) } if rules + h[:behavior] = behavior if behavior + h[:mode] = mode if mode + h[:directories] = directories if directories + h[:destination] = destination if destination + h + end + + private + + def normalize_rule(rule) + return rule unless rule.is_a?(Hash) + + # Convert snake_case to camelCase + # Note: behavior is NOT part of PermissionRuleValue per TypeScript SDK + { + toolName: rule[:tool_name] || rule[:toolName], + ruleContent: rule[:rule_content] || rule[:ruleContent] + }.compact + end + end +end diff --git a/lib/claude_agent/permissions.rb b/lib/claude_agent/permissions.rb deleted file mode 100644 index 719ed9d..0000000 --- a/lib/claude_agent/permissions.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Result of a permission check (allow) - # - # @example Allow with modified input - # PermissionResultAllow.new( - # updated_input: input.merge("safe" => true), - # tool_use_id: "tool_123" - # ) - # - class PermissionResultAllow < ImmutableRecord - attribute :updated_input, default: nil - attribute :updated_permissions, default: nil - attribute :tool_use_id, default: nil - - def behavior - "allow" - end - - def to_h - h = { behavior: "allow" } - h[:updatedInput] = updated_input if updated_input - h[:updatedPermissions] = updated_permissions&.map(&:to_h) if updated_permissions - h[:toolUseID] = tool_use_id if tool_use_id - h - end - end - - # Result of a permission check (deny) - # - # @example Deny with message - # PermissionResultDeny.new( - # message: "Operation not allowed", - # interrupt: true, - # tool_use_id: "tool_123" - # ) - # - class PermissionResultDeny < ImmutableRecord - attribute :message, default: "" - attribute :interrupt, default: false - attribute :tool_use_id, default: nil - - def behavior - "deny" - end - - def to_h - h = { behavior: "deny", message: message, interrupt: interrupt } - h[:toolUseID] = tool_use_id if tool_use_id - h - end - end - - # Valid permission update types - PERMISSION_UPDATE_TYPES = %w[ - addRules - replaceRules - removeRules - setMode - addDirectories - removeDirectories - ].freeze - - # Permission update request - # - # @example Add rules - # PermissionUpdate.new( - # type: "addRules", - # rules: [{tool_name: "Read", behavior: "allow"}] - # ) - # - class PermissionUpdate < ImmutableRecord - attribute :type - attribute :rules, default: nil - attribute :behavior, default: nil - attribute :mode, default: nil - attribute :directories, default: nil - attribute :destination, default: nil - - def to_h - h = { type: type } - h[:rules] = rules.map { |r| normalize_rule(r) } if rules - h[:behavior] = behavior if behavior - h[:mode] = mode if mode - h[:directories] = directories if directories - h[:destination] = destination if destination - h - end - - private - - def normalize_rule(rule) - return rule unless rule.is_a?(Hash) - - # Convert snake_case to camelCase - # Note: behavior is NOT part of PermissionRuleValue per TypeScript SDK - { - toolName: rule[:tool_name] || rule[:toolName], - ruleContent: rule[:rule_content] || rule[:ruleContent] - }.compact - end - end - - # Permission rule value (TypeScript SDK parity) - # Note: behavior is on PermissionUpdate, not on individual rules - # - class PermissionRuleValue < ImmutableRecord - attribute :tool_name, default: nil - attribute :rule_content, default: nil - - def to_h - { - toolName: tool_name, - ruleContent: rule_content - }.compact - end - end - - # Valid permission update destinations (TypeScript SDK parity) - PERMISSION_UPDATE_DESTINATIONS = %w[ - userSettings - projectSettings - localSettings - session - cliArg - ].freeze - - # Context provided to can_use_tool callbacks (TypeScript SDK parity) - # - # @example - # context = ToolPermissionContext.new( - # permission_suggestions: [update1, update2], - # blocked_path: "/etc/passwd", - # decision_reason: "Path outside allowed directories", - # tool_use_id: "tool_123", - # agent_id: "agent_456", - # title: "Claude wants to read /etc/passwd", - # display_name: "Read file", - # signal: abort_signal - # ) - # - class ToolPermissionContext < ImmutableRecord - attribute :permission_suggestions, default: nil - attribute :blocked_path, default: nil - attribute :decision_reason, default: nil - attribute :tool_use_id, default: nil - attribute :agent_id, default: nil - attribute :signal, default: nil - attribute :description, default: nil - attribute :title, default: nil - attribute :display_name, default: nil - attribute :request, default: nil - end -end diff --git a/lib/claude_agent/process_error.rb b/lib/claude_agent/process_error.rb new file mode 100644 index 0000000..c6f0968 --- /dev/null +++ b/lib/claude_agent/process_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when the CLI process exits with an error + class ProcessError < Error + attr_reader :exit_code, :stderr + + def initialize(message = "CLI process failed", exit_code: nil, stderr: nil) + @exit_code = exit_code + @stderr = stderr + full_message = message + full_message += " (exit code: #{exit_code})" if exit_code + full_message += "\nStderr: #{stderr}" if stderr && !stderr.empty? + super(full_message) + end + end +end diff --git a/lib/claude_agent/query.rb b/lib/claude_agent/query.rb index 745df0f..92b6e6b 100644 --- a/lib/claude_agent/query.rb +++ b/lib/claude_agent/query.rb @@ -1,7 +1,12 @@ # frozen_string_literal: true module ClaudeAgent - class << self + # One-shot query methods for ClaudeAgent. + # + # Extended into ClaudeAgent's singleton class to provide + # {ClaudeAgent.query} and {ClaudeAgent.query_turn}. + # + module Query # One-shot query to Claude Code CLI # # This is a simple, stateless interface for sending a single prompt diff --git a/lib/claude_agent/sandbox_filesystem_config.rb b/lib/claude_agent/sandbox_filesystem_config.rb new file mode 100644 index 0000000..9476dba --- /dev/null +++ b/lib/claude_agent/sandbox_filesystem_config.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Filesystem-specific configuration for sandbox mode (TypeScript SDK v0.2.77 parity) + # + # @example + # filesystem = SandboxFilesystemConfig.new( + # allow_write: ["/tmp/*"], + # deny_write: ["/etc/*"], + # deny_read: ["/secrets/*"], + # allow_read: ["/secrets/public/*"], + # allow_managed_read_paths_only: false + # ) + # + class SandboxFilesystemConfig < ImmutableRecord + attribute :allow_write, default: [] + attribute :deny_write, default: [] + attribute :deny_read, default: [] + attribute :allow_read, default: [] + attribute :allow_managed_read_paths_only, default: false + + def to_h + result = {} + result[:allowWrite] = allow_write unless allow_write.empty? + result[:denyWrite] = deny_write unless deny_write.empty? + result[:denyRead] = deny_read unless deny_read.empty? + result[:allowRead] = allow_read unless allow_read.empty? + result[:allowManagedReadPathsOnly] = allow_managed_read_paths_only if allow_managed_read_paths_only + result + end + end +end diff --git a/lib/claude_agent/sandbox_ignore_violations.rb b/lib/claude_agent/sandbox_ignore_violations.rb new file mode 100644 index 0000000..6d9a957 --- /dev/null +++ b/lib/claude_agent/sandbox_ignore_violations.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Configuration for ignoring specific sandbox violations (TypeScript SDK parity) + # + # @example + # ignore = SandboxIgnoreViolations.new( + # file: ["/tmp/*"], + # network: ["localhost:*"] + # ) + # + class SandboxIgnoreViolations < ImmutableRecord + attribute :file, default: [] + attribute :network, default: [] + + def to_h + result = {} + result[:file] = file unless file.empty? + result[:network] = network unless network.empty? + result + end + end +end diff --git a/lib/claude_agent/sandbox_network_config.rb b/lib/claude_agent/sandbox_network_config.rb new file mode 100644 index 0000000..404bdc3 --- /dev/null +++ b/lib/claude_agent/sandbox_network_config.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Network-specific configuration for sandbox mode (TypeScript SDK parity) + # + # @example + # network = SandboxNetworkConfig.new( + # allow_local_binding: true, + # allow_unix_sockets: ["/var/run/docker.sock"], + # allowed_domains: ["api.example.com"] + # ) + # + class SandboxNetworkConfig < ImmutableRecord + attribute :allowed_domains, default: [] + attribute :allow_local_binding, default: false + attribute :allow_unix_sockets, default: [] + attribute :allow_all_unix_sockets, default: false + attribute :allow_managed_domains_only, default: false + attribute :http_proxy_port, default: nil + attribute :socks_proxy_port, default: nil + + def to_h + result = {} + result[:allowedDomains] = allowed_domains unless allowed_domains.empty? + result[:allowLocalBinding] = allow_local_binding if allow_local_binding + result[:allowUnixSockets] = allow_unix_sockets unless allow_unix_sockets.empty? + result[:allowAllUnixSockets] = allow_all_unix_sockets if allow_all_unix_sockets + result[:allowManagedDomainsOnly] = allow_managed_domains_only if allow_managed_domains_only + result[:httpProxyPort] = http_proxy_port if http_proxy_port + result[:socksProxyPort] = socks_proxy_port if socks_proxy_port + result + end + end +end diff --git a/lib/claude_agent/sandbox_ripgrep_config.rb b/lib/claude_agent/sandbox_ripgrep_config.rb new file mode 100644 index 0000000..f512882 --- /dev/null +++ b/lib/claude_agent/sandbox_ripgrep_config.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Custom ripgrep configuration for sandbox mode (TypeScript SDK parity) + # + # @example + # ripgrep = SandboxRipgrepConfig.new( + # command: "/usr/local/bin/rg", + # args: ["--hidden"] + # ) + # + class SandboxRipgrepConfig < ImmutableRecord + attribute :command + attribute :args, default: nil + + def to_h + result = { command: command } + result[:args] = args if args + result + end + end +end diff --git a/lib/claude_agent/sandbox_settings.rb b/lib/claude_agent/sandbox_settings.rb index 8be50eb..9aa743b 100644 --- a/lib/claude_agent/sandbox_settings.rb +++ b/lib/claude_agent/sandbox_settings.rb @@ -1,105 +1,6 @@ # frozen_string_literal: true module ClaudeAgent - # Network-specific configuration for sandbox mode (TypeScript SDK parity) - # - # @example - # network = SandboxNetworkConfig.new( - # allow_local_binding: true, - # allow_unix_sockets: ["/var/run/docker.sock"], - # allowed_domains: ["api.example.com"] - # ) - # - class SandboxNetworkConfig < ImmutableRecord - attribute :allowed_domains, default: [] - attribute :allow_local_binding, default: false - attribute :allow_unix_sockets, default: [] - attribute :allow_all_unix_sockets, default: false - attribute :allow_managed_domains_only, default: false - attribute :http_proxy_port, default: nil - attribute :socks_proxy_port, default: nil - - def to_h - result = {} - result[:allowedDomains] = allowed_domains unless allowed_domains.empty? - result[:allowLocalBinding] = allow_local_binding if allow_local_binding - result[:allowUnixSockets] = allow_unix_sockets unless allow_unix_sockets.empty? - result[:allowAllUnixSockets] = allow_all_unix_sockets if allow_all_unix_sockets - result[:allowManagedDomainsOnly] = allow_managed_domains_only if allow_managed_domains_only - result[:httpProxyPort] = http_proxy_port if http_proxy_port - result[:socksProxyPort] = socks_proxy_port if socks_proxy_port - result - end - end - - # Configuration for ignoring specific sandbox violations (TypeScript SDK parity) - # - # @example - # ignore = SandboxIgnoreViolations.new( - # file: ["/tmp/*"], - # network: ["localhost:*"] - # ) - # - class SandboxIgnoreViolations < ImmutableRecord - attribute :file, default: [] - attribute :network, default: [] - - def to_h - result = {} - result[:file] = file unless file.empty? - result[:network] = network unless network.empty? - result - end - end - - # Custom ripgrep configuration for sandbox mode (TypeScript SDK parity) - # - # @example - # ripgrep = SandboxRipgrepConfig.new( - # command: "/usr/local/bin/rg", - # args: ["--hidden"] - # ) - # - class SandboxRipgrepConfig < ImmutableRecord - attribute :command - attribute :args, default: nil - - def to_h - result = { command: command } - result[:args] = args if args - result - end - end - - # Filesystem-specific configuration for sandbox mode (TypeScript SDK v0.2.77 parity) - # - # @example - # filesystem = SandboxFilesystemConfig.new( - # allow_write: ["/tmp/*"], - # deny_write: ["/etc/*"], - # deny_read: ["/secrets/*"], - # allow_read: ["/secrets/public/*"], - # allow_managed_read_paths_only: false - # ) - # - class SandboxFilesystemConfig < ImmutableRecord - attribute :allow_write, default: [] - attribute :deny_write, default: [] - attribute :deny_read, default: [] - attribute :allow_read, default: [] - attribute :allow_managed_read_paths_only, default: false - - def to_h - result = {} - result[:allowWrite] = allow_write unless allow_write.empty? - result[:denyWrite] = deny_write unless deny_write.empty? - result[:denyRead] = deny_read unless deny_read.empty? - result[:allowRead] = allow_read unless allow_read.empty? - result[:allowManagedReadPathsOnly] = allow_managed_read_paths_only if allow_managed_read_paths_only - result - end - end - # Sandbox configuration for command execution (TypeScript SDK parity) # # @example Basic sandbox diff --git a/lib/claude_agent/session_options.rb b/lib/claude_agent/session_options.rb new file mode 100644 index 0000000..15cd1d0 --- /dev/null +++ b/lib/claude_agent/session_options.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ClaudeAgent + # V2 Session options (subset of full Options) + # V2 API - UNSTABLE + # @alpha + # + # @example + # options = SessionOptions.new( + # model: "claude-sonnet-4-5-20250929", + # permission_mode: "acceptEdits" + # ) + # + class SessionOptions < ImmutableRecord + attribute :model + attribute :path_to_claude_code_executable, default: nil + attribute :env, default: nil + attribute :allowed_tools, default: nil + attribute :disallowed_tools, default: nil + attribute :can_use_tool, default: nil + attribute :hooks, default: nil + attribute :permission_mode, default: nil + end +end diff --git a/lib/claude_agent/spawn.rb b/lib/claude_agent/spawn.rb deleted file mode 100644 index 329b9be..0000000 --- a/lib/claude_agent/spawn.rb +++ /dev/null @@ -1,237 +0,0 @@ -# frozen_string_literal: true - -require "open3" - -module ClaudeAgent - # Options passed to a spawn function for creating a Claude Code process (TypeScript SDK parity) - # - # This allows custom process creation for VMs, containers, remote execution, etc. - # - # @example - # options = SpawnOptions.new( - # command: "/usr/local/bin/claude", - # args: ["--output-format", "stream-json"], - # cwd: "/my/project", - # env: { "CLAUDE_CODE_ENTRYPOINT" => "sdk-rb" } - # ) - # - class SpawnOptions < ImmutableRecord - attribute :command - attribute :args, default: [] - attribute :cwd, default: nil - attribute :env, default: {} - attribute :abort_signal, default: nil - - # Get the full command line as an array - # @return [Array] - def to_command_array - [ command, *args ] - end - end - - # Interface for spawned process (TypeScript SDK parity) - # - # Custom spawn functions must return an object that responds to these methods. - # This allows wrapping SSH connections, Docker exec, VM instances, etc. - # - # @abstract Implement all methods for custom process types - # - module SpawnedProcess - # Write data to process stdin - # @param data [String] Data to write - # @return [void] - def write(data) - raise NotImplementedError - end - - # Read from process stdout - # @yield [String] Lines from stdout - # @return [void] - def read_stdout - raise NotImplementedError - end - - # Read from process stderr - # @yield [String] Lines from stderr - # @return [void] - def read_stderr - raise NotImplementedError - end - - # Close stdin to signal end of input - # @return [void] - def close_stdin - raise NotImplementedError - end - - # Terminate the process gracefully (SIGTERM equivalent) - # @param timeout [Numeric] Seconds to wait before force kill - # @return [void] - def terminate(timeout: 5) - raise NotImplementedError - end - - # Force kill the process (SIGKILL equivalent) - # @return [void] - def kill - raise NotImplementedError - end - - # Check if process is still running - # @return [Boolean] - def running? - raise NotImplementedError - end - - # Get process exit status - # @return [Integer, nil] - def exit_status - raise NotImplementedError - end - - # Close all streams - # @return [void] - def close - raise NotImplementedError - end - end - - # Local spawned process wrapping Open3.popen3 (TypeScript SDK parity) - # - # This is the default implementation used when no custom spawn function is provided. - # - # @example - # process = LocalSpawnedProcess.spawn(options) - # process.write('{"type":"user"}\n') - # process.read_stdout { |line| puts line } - # process.close - # - class LocalSpawnedProcess - include SpawnedProcess - - attr_reader :pid, :stdin, :stdout, :stderr, :wait_thread - - # Spawn a new local process - # @param spawn_options [SpawnOptions] Options for spawning - # @return [LocalSpawnedProcess] - def self.spawn(spawn_options) - cmd = spawn_options.to_command_array - env = spawn_options.env || {} - cwd = spawn_options.cwd - - opts = {} - opts[:chdir] = cwd if cwd && Dir.exist?(cwd) - - stdin, stdout, stderr, wait_thread = Open3.popen3(env, *cmd, **opts) - - new(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread) - end - - def initialize(stdin:, stdout:, stderr:, wait_thread:) - @stdin = stdin - @stdout = stdout - @stderr = stderr - @wait_thread = wait_thread - @killed = false - @mutex = Mutex.new - end - - def write(data) - @mutex.synchronize do - return if @stdin.closed? - - @stdin.write(data) - @stdin.write("\n") unless data.end_with?("\n") - @stdin.flush - end - rescue Errno::EPIPE - # Process terminated - end - - def read_stdout(&block) - return enum_for(:read_stdout) unless block_given? - - @stdout.each_line(&block) - rescue IOError - # Stream closed - end - - def read_stderr(&block) - return enum_for(:read_stderr) unless block_given? - - @stderr.each_line(&block) - rescue IOError - # Stream closed - end - - def close_stdin - @mutex.synchronize do - @stdin.close unless @stdin.closed? - end - end - - def terminate(timeout: 5) - return unless running? - - pid = @wait_thread.pid - begin - Process.kill("TERM", pid) - rescue Errno::ESRCH, Errno::EPERM - return - end - - unless @wait_thread.join(timeout) - kill - end - end - - def kill - return unless running? - - @mutex.synchronize { @killed = true } - pid = @wait_thread.pid - begin - Process.kill("KILL", pid) - rescue Errno::ESRCH, Errno::EPERM - # Already dead - end - end - - def running? - @wait_thread.alive? - end - - def exit_status - @wait_thread.value&.exitstatus - end - - def killed? - @killed - end - - def close - @mutex.synchronize do - @stdin.close unless @stdin.closed? - @stdout.close unless @stdout.closed? - @stderr.close unless @stderr.closed? - end - @wait_thread.value - end - end - - # Default spawn function for local subprocess execution - # - # This lambda is used when no custom spawn_claude_code_process is provided. - # It creates a LocalSpawnedProcess using Open3.popen3. - # - # @example Custom spawn for Docker - # custom_spawn = ->(opts) { - # docker_cmd = ["docker", "exec", "-i", "my-container", opts.command, *opts.args] - # DockerProcess.new(docker_cmd, env: opts.env) - # } - # options = ClaudeAgent::Options.new(spawn_claude_code_process: custom_spawn) - # - DEFAULT_SPAWN = ->(spawn_options) { - LocalSpawnedProcess.spawn(spawn_options) - }.freeze -end diff --git a/lib/claude_agent/spawn_options.rb b/lib/claude_agent/spawn_options.rb new file mode 100644 index 0000000..f06f230 --- /dev/null +++ b/lib/claude_agent/spawn_options.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Options passed to a spawn function for creating a Claude Code process (TypeScript SDK parity) + # + # This allows custom process creation for VMs, containers, remote execution, etc. + # + # @example + # options = SpawnOptions.new( + # command: "/usr/local/bin/claude", + # args: ["--output-format", "stream-json"], + # cwd: "/my/project", + # env: { "CLAUDE_CODE_ENTRYPOINT" => "sdk-rb" } + # ) + # + class SpawnOptions < ImmutableRecord + attribute :command + attribute :args, default: [] + attribute :cwd, default: nil + attribute :env, default: {} + attribute :abort_signal, default: nil + + # Get the full command line as an array + # @return [Array] + def to_command_array + [ command, *args ] + end + end +end diff --git a/lib/claude_agent/spawned_process.rb b/lib/claude_agent/spawned_process.rb new file mode 100644 index 0000000..2cf9750 --- /dev/null +++ b/lib/claude_agent/spawned_process.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Interface for spawned process (TypeScript SDK parity) + # + # Custom spawn functions must return an object that responds to these methods. + # This allows wrapping SSH connections, Docker exec, VM instances, etc. + # + # @abstract Implement all methods for custom process types + # + module SpawnedProcess + # Write data to process stdin + # @param data [String] Data to write + # @return [void] + def write(data) + raise NotImplementedError + end + + # Read from process stdout + # @yield [String] Lines from stdout + # @return [void] + def read_stdout + raise NotImplementedError + end + + # Read from process stderr + # @yield [String] Lines from stderr + # @return [void] + def read_stderr + raise NotImplementedError + end + + # Close stdin to signal end of input + # @return [void] + def close_stdin + raise NotImplementedError + end + + # Terminate the process gracefully (SIGTERM equivalent) + # @param timeout [Numeric] Seconds to wait before force kill + # @return [void] + def terminate(timeout: 5) + raise NotImplementedError + end + + # Force kill the process (SIGKILL equivalent) + # @return [void] + def kill + raise NotImplementedError + end + + # Check if process is still running + # @return [Boolean] + def running? + raise NotImplementedError + end + + # Get process exit status + # @return [Integer, nil] + def exit_status + raise NotImplementedError + end + + # Close all streams + # @return [void] + def close + raise NotImplementedError + end + end +end diff --git a/lib/claude_agent/timeout_error.rb b/lib/claude_agent/timeout_error.rb new file mode 100644 index 0000000..f29a941 --- /dev/null +++ b/lib/claude_agent/timeout_error.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Raised when a control protocol request times out + class TimeoutError < Error + attr_reader :request_id, :timeout_seconds + + def initialize(message = "Request timed out", request_id: nil, timeout_seconds: nil) + @request_id = request_id + @timeout_seconds = timeout_seconds + full_message = message + full_message += " (request_id: #{request_id})" if request_id + full_message += " after #{timeout_seconds}s" if timeout_seconds + super(full_message) + end + end +end diff --git a/lib/claude_agent/tool_permission_context.rb b/lib/claude_agent/tool_permission_context.rb new file mode 100644 index 0000000..71c4f14 --- /dev/null +++ b/lib/claude_agent/tool_permission_context.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Context provided to can_use_tool callbacks (TypeScript SDK parity) + # + # @example + # context = ToolPermissionContext.new( + # permission_suggestions: [update1, update2], + # blocked_path: "/etc/passwd", + # decision_reason: "Path outside allowed directories", + # tool_use_id: "tool_123", + # agent_id: "agent_456", + # title: "Claude wants to read /etc/passwd", + # display_name: "Read file", + # signal: abort_signal + # ) + # + class ToolPermissionContext < ImmutableRecord + attribute :permission_suggestions, default: nil + attribute :blocked_path, default: nil + attribute :decision_reason, default: nil + attribute :tool_use_id, default: nil + attribute :agent_id, default: nil + attribute :signal, default: nil + attribute :description, default: nil + attribute :title, default: nil + attribute :display_name, default: nil + attribute :request, default: nil + end +end diff --git a/lib/claude_agent/types.rb b/lib/claude_agent/types.rb deleted file mode 100644 index 85e2ec9..0000000 --- a/lib/claude_agent/types.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require_relative "types/tools" -require_relative "types/models" -require_relative "types/mcp" -require_relative "types/sessions" -require_relative "types/operations" - -module ClaudeAgent - # Assistant message error types (TypeScript SDK parity) - # Used to categorize errors returned by the assistant - ASSISTANT_MESSAGE_ERROR_TYPES = %w[ - authentication_failed - billing_error - rate_limit - invalid_request - server_error - unknown - max_output_tokens - ].freeze - - # API key source types (TypeScript SDK parity) - # Indicates where the API key was sourced from - API_KEY_SOURCES = %w[user project org temporary oauth].freeze -end diff --git a/lib/claude_agent/types/account_info.rb b/lib/claude_agent/types/account_info.rb new file mode 100644 index 0000000..ccc03f5 --- /dev/null +++ b/lib/claude_agent/types/account_info.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Return type for account_info() (TypeScript SDK parity) + # + # @example + # info = AccountInfo.new(email: "user@example.com", organization: "Acme Corp") + # + class AccountInfo < ImmutableRecord + attribute :email, default: nil + attribute :organization, default: nil + attribute :subscription_type, default: nil + attribute :token_source, default: nil + attribute :api_key_source, default: nil + end +end diff --git a/lib/claude_agent/types/agent_definition.rb b/lib/claude_agent/types/agent_definition.rb new file mode 100644 index 0000000..ec050a3 --- /dev/null +++ b/lib/claude_agent/types/agent_definition.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Agent definition for custom subagents (TypeScript SDK parity) + # + # @example Basic agent + # agent = AgentDefinition.new( + # description: "Runs tests and reports results", + # prompt: "You are a test runner...", + # tools: ["Read", "Grep", "Glob", "Bash"], + # model: "haiku" + # ) + # + # @example Agent with skills and max_turns + # agent = AgentDefinition.new( + # description: "Research agent with specialized skills", + # prompt: "You are a research expert...", + # skills: ["web-search", "summarization"], + # max_turns: 10 + # ) + # + class AgentDefinition < ImmutableRecord + attribute :description + attribute :prompt + attribute :tools, default: nil + attribute :disallowed_tools, default: nil + attribute :model, default: nil + attribute :mcp_servers, default: nil + attribute :critical_system_reminder, default: nil + attribute :skills, default: nil + attribute :max_turns, default: nil + + def to_h + result = { + description: description, + prompt: prompt + } + result[:tools] = tools if tools + result[:disallowedTools] = disallowed_tools if disallowed_tools + result[:model] = model if model + result[:mcpServers] = mcp_servers if mcp_servers + result[:criticalSystemReminder_EXPERIMENTAL] = critical_system_reminder if critical_system_reminder + result[:skills] = skills if skills + result[:maxTurns] = max_turns if max_turns + result + end + end +end diff --git a/lib/claude_agent/types/agent_info.rb b/lib/claude_agent/types/agent_info.rb new file mode 100644 index 0000000..46b28cd --- /dev/null +++ b/lib/claude_agent/types/agent_info.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Return type for supported_agents() (TypeScript SDK v0.2.63 parity) + # + # @example + # agent = AgentInfo.new(name: "Explore", description: "Search agent", model: "haiku") + # agent.name # => "Explore" + # agent.description # => "Search agent" + # + class AgentInfo < ImmutableRecord + attribute :name + attribute :description, default: nil + attribute :model, default: nil + end +end diff --git a/lib/claude_agent/types/fork_session_result.rb b/lib/claude_agent/types/fork_session_result.rb new file mode 100644 index 0000000..824c240 --- /dev/null +++ b/lib/claude_agent/types/fork_session_result.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Result of forking a session (TypeScript SDK v0.2.76 parity) + # + # @example + # result = ClaudeAgent.fork_session("abc-123") + # puts result.session_id # => new UUID + # + class ForkSessionResult < ImmutableRecord + attribute :session_id + end +end diff --git a/lib/claude_agent/types/initialization_result.rb b/lib/claude_agent/types/initialization_result.rb new file mode 100644 index 0000000..da41fe3 --- /dev/null +++ b/lib/claude_agent/types/initialization_result.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Composite initialization result from supported_commands request (TypeScript SDK parity) + # + # @example + # result = InitializationResult.new( + # commands: [SlashCommand.new(name: "commit")], + # output_style: "default", + # available_output_styles: ["default", "concise"], + # models: [ModelInfo.new(value: "claude-sonnet")], + # account: AccountInfo.new(email: "user@example.com"), + # agents: [AgentInfo.new(name: "Explore")] + # ) + # + class InitializationResult < ImmutableRecord + attribute :commands, default: [] + attribute :output_style, default: nil + attribute :available_output_styles, default: [] + attribute :models, default: [] + attribute :account, default: nil + attribute :agents, default: [] + attribute :fast_mode_state, default: nil + end +end diff --git a/lib/claude_agent/types/mcp.rb b/lib/claude_agent/types/mcp_server_status.rb similarity index 59% rename from lib/claude_agent/types/mcp.rb rename to lib/claude_agent/types/mcp_server_status.rb index 2c716d3..470ab40 100644 --- a/lib/claude_agent/types/mcp.rb +++ b/lib/claude_agent/types/mcp_server_status.rb @@ -16,19 +16,4 @@ class McpServerStatus < ImmutableRecord attribute :scope, default: nil attribute :tools, default: nil end - - # Result of set_mcp_servers() control method (TypeScript SDK parity) - # - # @example - # result = McpSetServersResult.new( - # added: ["server1"], - # removed: ["old-server"], - # errors: {"server2" => "Connection failed"} - # ) - # - class McpSetServersResult < ImmutableRecord - attribute :added, default: [] - attribute :removed, default: [] - attribute :errors, default: {} - end end diff --git a/lib/claude_agent/types/mcp_set_servers_result.rb b/lib/claude_agent/types/mcp_set_servers_result.rb new file mode 100644 index 0000000..2afd0f6 --- /dev/null +++ b/lib/claude_agent/types/mcp_set_servers_result.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Result of set_mcp_servers() control method (TypeScript SDK parity) + # + # @example + # result = McpSetServersResult.new( + # added: ["server1"], + # removed: ["old-server"], + # errors: {"server2" => "Connection failed"} + # ) + # + class McpSetServersResult < ImmutableRecord + attribute :added, default: [] + attribute :removed, default: [] + attribute :errors, default: {} + end +end diff --git a/lib/claude_agent/types/model_info.rb b/lib/claude_agent/types/model_info.rb new file mode 100644 index 0000000..afb22d3 --- /dev/null +++ b/lib/claude_agent/types/model_info.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Return type for supported_models() (TypeScript SDK parity) + # + # @example + # model = ModelInfo.new(value: "claude-3-opus", display_name: "Claude 3 Opus", description: "Most capable") + # model.value # => "claude-3-opus" + # model.display_name # => "Claude 3 Opus" + # + class ModelInfo < ImmutableRecord + attribute :value + attribute :display_name, default: nil + attribute :description, default: nil + attribute :supports_effort, default: nil + attribute :supported_effort_levels, default: nil + attribute :supports_adaptive_thinking, default: nil + attribute :supports_fast_mode, default: nil + attribute :supports_auto_mode, default: nil + end +end diff --git a/lib/claude_agent/types/model_usage.rb b/lib/claude_agent/types/model_usage.rb new file mode 100644 index 0000000..60952f0 --- /dev/null +++ b/lib/claude_agent/types/model_usage.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Per-model usage statistics returned in result messages (TypeScript SDK parity) + # + # @example + # usage = ModelUsage.new(input_tokens: 100, output_tokens: 50, cost_usd: 0.01, max_output_tokens: 4096) + # + class ModelUsage < ImmutableRecord + attribute :input_tokens, default: 0 + attribute :output_tokens, default: 0 + attribute :cache_read_input_tokens, default: 0 + attribute :cache_creation_input_tokens, default: 0 + attribute :web_search_requests, default: 0 + attribute :cost_usd, default: 0.0 + attribute :context_window, default: nil + attribute :max_output_tokens, default: nil + end +end diff --git a/lib/claude_agent/types/models.rb b/lib/claude_agent/types/models.rb deleted file mode 100644 index 95b5dff..0000000 --- a/lib/claude_agent/types/models.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Return type for supported_models() (TypeScript SDK parity) - # - # @example - # model = ModelInfo.new(value: "claude-3-opus", display_name: "Claude 3 Opus", description: "Most capable") - # model.value # => "claude-3-opus" - # model.display_name # => "Claude 3 Opus" - # - class ModelInfo < ImmutableRecord - attribute :value - attribute :display_name, default: nil - attribute :description, default: nil - attribute :supports_effort, default: nil - attribute :supported_effort_levels, default: nil - attribute :supports_adaptive_thinking, default: nil - attribute :supports_fast_mode, default: nil - attribute :supports_auto_mode, default: nil - end - - # Per-model usage statistics returned in result messages (TypeScript SDK parity) - # - # @example - # usage = ModelUsage.new(input_tokens: 100, output_tokens: 50, cost_usd: 0.01, max_output_tokens: 4096) - # - class ModelUsage < ImmutableRecord - attribute :input_tokens, default: 0 - attribute :output_tokens, default: 0 - attribute :cache_read_input_tokens, default: 0 - attribute :cache_creation_input_tokens, default: 0 - attribute :web_search_requests, default: 0 - attribute :cost_usd, default: 0.0 - attribute :context_window, default: nil - attribute :max_output_tokens, default: nil - end - - # Return type for account_info() (TypeScript SDK parity) - # - # @example - # info = AccountInfo.new(email: "user@example.com", organization: "Acme Corp") - # - class AccountInfo < ImmutableRecord - attribute :email, default: nil - attribute :organization, default: nil - attribute :subscription_type, default: nil - attribute :token_source, default: nil - attribute :api_key_source, default: nil - end - - # Return type for supported_agents() (TypeScript SDK v0.2.63 parity) - # - # @example - # agent = AgentInfo.new(name: "Explore", description: "Search agent", model: "haiku") - # agent.name # => "Explore" - # agent.description # => "Search agent" - # - class AgentInfo < ImmutableRecord - attribute :name - attribute :description, default: nil - attribute :model, default: nil - end - - # Agent definition for custom subagents (TypeScript SDK parity) - # - # @example Basic agent - # agent = AgentDefinition.new( - # description: "Runs tests and reports results", - # prompt: "You are a test runner...", - # tools: ["Read", "Grep", "Glob", "Bash"], - # model: "haiku" - # ) - # - # @example Agent with skills and max_turns - # agent = AgentDefinition.new( - # description: "Research agent with specialized skills", - # prompt: "You are a research expert...", - # skills: ["web-search", "summarization"], - # max_turns: 10 - # ) - # - class AgentDefinition < ImmutableRecord - attribute :description - attribute :prompt - attribute :tools, default: nil - attribute :disallowed_tools, default: nil - attribute :model, default: nil - attribute :mcp_servers, default: nil - attribute :critical_system_reminder, default: nil - attribute :skills, default: nil - attribute :max_turns, default: nil - - def to_h - result = { - description: description, - prompt: prompt - } - result[:tools] = tools if tools - result[:disallowedTools] = disallowed_tools if disallowed_tools - result[:model] = model if model - result[:mcpServers] = mcp_servers if mcp_servers - result[:criticalSystemReminder_EXPERIMENTAL] = critical_system_reminder if critical_system_reminder - result[:skills] = skills if skills - result[:maxTurns] = max_turns if max_turns - result - end - end - - # Composite initialization result from supported_commands request (TypeScript SDK parity) - # - # @example - # result = InitializationResult.new( - # commands: [SlashCommand.new(name: "commit")], - # output_style: "default", - # available_output_styles: ["default", "concise"], - # models: [ModelInfo.new(value: "claude-sonnet")], - # account: AccountInfo.new(email: "user@example.com"), - # agents: [AgentInfo.new(name: "Explore")] - # ) - # - class InitializationResult < ImmutableRecord - attribute :commands, default: [] - attribute :output_style, default: nil - attribute :available_output_styles, default: [] - attribute :models, default: [] - attribute :account, default: nil - attribute :agents, default: [] - attribute :fast_mode_state, default: nil - end -end diff --git a/lib/claude_agent/types/operations.rb b/lib/claude_agent/types/operations.rb deleted file mode 100644 index 816c04a..0000000 --- a/lib/claude_agent/types/operations.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module ClaudeAgent - # Task usage statistics for TaskNotificationMessage (TypeScript SDK parity) - # - # @example - # usage = TaskUsage.new(total_tokens: 5000, tool_uses: 3, duration_ms: 2500) - # - class TaskUsage < ImmutableRecord - attribute :total_tokens, default: 0 - attribute :tool_uses, default: 0 - attribute :duration_ms, default: 0 - end - - # Permission denial information in result messages (TypeScript SDK parity) - # - class SDKPermissionDenial < ImmutableRecord - attribute :tool_name - attribute :tool_use_id - attribute :tool_input - end - - # Result of rewind_files() control method (TypeScript SDK parity) - # - # @example - # result = RewindFilesResult.new( - # can_rewind: true, - # files_changed: ["src/foo.rb", "src/bar.rb"], - # insertions: 10, - # deletions: 5 - # ) - # - class RewindFilesResult < ImmutableRecord - attribute :can_rewind - attribute :error, default: nil - attribute :files_changed, default: nil - attribute :insertions, default: nil - attribute :deletions, default: nil - end -end diff --git a/lib/claude_agent/types/rewind_files_result.rb b/lib/claude_agent/types/rewind_files_result.rb new file mode 100644 index 0000000..d603c57 --- /dev/null +++ b/lib/claude_agent/types/rewind_files_result.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Result of rewind_files() control method (TypeScript SDK parity) + # + # @example + # result = RewindFilesResult.new( + # can_rewind: true, + # files_changed: ["src/foo.rb", "src/bar.rb"], + # insertions: 10, + # deletions: 5 + # ) + # + class RewindFilesResult < ImmutableRecord + attribute :can_rewind + attribute :error, default: nil + attribute :files_changed, default: nil + attribute :insertions, default: nil + attribute :deletions, default: nil + end +end diff --git a/lib/claude_agent/types/sdk_permission_denial.rb b/lib/claude_agent/types/sdk_permission_denial.rb new file mode 100644 index 0000000..29e8454 --- /dev/null +++ b/lib/claude_agent/types/sdk_permission_denial.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Permission denial information in result messages (TypeScript SDK parity) + # + class SDKPermissionDenial < ImmutableRecord + attribute :tool_name + attribute :tool_use_id + attribute :tool_input + end +end diff --git a/lib/claude_agent/types/sessions.rb b/lib/claude_agent/types/session_info.rb similarity index 52% rename from lib/claude_agent/types/sessions.rb rename to lib/claude_agent/types/session_info.rb index 7307731..ffd5cc1 100644 --- a/lib/claude_agent/types/sessions.rb +++ b/lib/claude_agent/types/session_info.rb @@ -27,32 +27,4 @@ class SessionInfo < ImmutableRecord attribute :tag, default: nil attribute :created_at, default: nil end - - # Message from a session transcript returned by get_session_messages (TypeScript SDK v0.2.59 parity) - # - # @example - # msg = SessionMessage.new( - # type: "user", - # uuid: "abc-123", - # session_id: "def-456", - # message: { "role" => "user", "content" => [{ "type" => "text", "text" => "Hello" }] } - # ) - # - class SessionMessage < ImmutableRecord - attribute :type - attribute :uuid - attribute :session_id - attribute :message - attribute :parent_tool_use_id, default: nil - end - - # Result of forking a session (TypeScript SDK v0.2.76 parity) - # - # @example - # result = ClaudeAgent.fork_session("abc-123") - # puts result.session_id # => new UUID - # - class ForkSessionResult < ImmutableRecord - attribute :session_id - end end diff --git a/lib/claude_agent/types/session_message.rb b/lib/claude_agent/types/session_message.rb new file mode 100644 index 0000000..d854e02 --- /dev/null +++ b/lib/claude_agent/types/session_message.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Message from a session transcript returned by get_session_messages (TypeScript SDK v0.2.59 parity) + # + # @example + # msg = SessionMessage.new( + # type: "user", + # uuid: "abc-123", + # session_id: "def-456", + # message: { "role" => "user", "content" => [{ "type" => "text", "text" => "Hello" }] } + # ) + # + class SessionMessage < ImmutableRecord + attribute :type + attribute :uuid + attribute :session_id + attribute :message + attribute :parent_tool_use_id, default: nil + end +end diff --git a/lib/claude_agent/types/tools.rb b/lib/claude_agent/types/slash_command.rb similarity index 55% rename from lib/claude_agent/types/tools.rb rename to lib/claude_agent/types/slash_command.rb index d6256db..bb2808d 100644 --- a/lib/claude_agent/types/tools.rb +++ b/lib/claude_agent/types/slash_command.rb @@ -1,21 +1,6 @@ # frozen_string_literal: true module ClaudeAgent - # Tools preset configuration (TypeScript SDK parity) - # - # @example - # preset = ToolsPreset.new(preset: "claude_code") - # options = ClaudeAgent::Options.new(tools: preset) - # - class ToolsPreset < ImmutableRecord - attribute :type, default: "preset" - attribute :preset, default: "claude_code" - - def to_h - { type: type, preset: preset } - end - end - # Return type for supported_commands() (TypeScript SDK parity) # # @example diff --git a/lib/claude_agent/types/task_usage.rb b/lib/claude_agent/types/task_usage.rb new file mode 100644 index 0000000..713b178 --- /dev/null +++ b/lib/claude_agent/types/task_usage.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Task usage statistics for TaskNotificationMessage (TypeScript SDK parity) + # + # @example + # usage = TaskUsage.new(total_tokens: 5000, tool_uses: 3, duration_ms: 2500) + # + class TaskUsage < ImmutableRecord + attribute :total_tokens, default: 0 + attribute :tool_uses, default: 0 + attribute :duration_ms, default: 0 + end +end diff --git a/lib/claude_agent/types/tools_preset.rb b/lib/claude_agent/types/tools_preset.rb new file mode 100644 index 0000000..effbc1d --- /dev/null +++ b/lib/claude_agent/types/tools_preset.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ClaudeAgent + # Tools preset configuration (TypeScript SDK parity) + # + # @example + # preset = ToolsPreset.new(preset: "claude_code") + # options = ClaudeAgent::Options.new(tools: preset) + # + class ToolsPreset < ImmutableRecord + attribute :type, default: "preset" + attribute :preset, default: "claude_code" + + def to_h + { type: type, preset: preset } + end + end +end diff --git a/lib/claude_agent/v2_session.rb b/lib/claude_agent/v2_session.rb index 660bae0..8291393 100644 --- a/lib/claude_agent/v2_session.rb +++ b/lib/claude_agent/v2_session.rb @@ -1,27 +1,6 @@ # frozen_string_literal: true module ClaudeAgent - # V2 Session options (subset of full Options) - # V2 API - UNSTABLE - # @alpha - # - # @example - # options = SessionOptions.new( - # model: "claude-sonnet-4-5-20250929", - # permission_mode: "acceptEdits" - # ) - # - class SessionOptions < ImmutableRecord - attribute :model - attribute :path_to_claude_code_executable, default: nil - attribute :env, default: nil - attribute :allowed_tools, default: nil - attribute :disallowed_tools, default: nil - attribute :can_use_tool, default: nil - attribute :hooks, default: nil - attribute :permission_mode, default: nil - end - # V2 API - UNSTABLE # Multi-turn session interface for persistent conversations. # @@ -115,80 +94,4 @@ def update_session_id @session_id = @client.server_info&.dig("session_id") end end - - class << self - # V2 API - UNSTABLE - # Create a persistent session for multi-turn conversations. - # - # @param options [Hash, SessionOptions] Session configuration - # @return [Session] A new session instance - # @alpha - # - # @example - # session = ClaudeAgent.unstable_v2_create_session(model: "claude-sonnet-4-5-20250929") - # - def unstable_v2_create_session(options) - V2Session.new(options) - end - - # V2 API - UNSTABLE - # Resume an existing session by ID. - # - # @param session_id [String] The session ID to resume - # @param options [Hash, SessionOptions] Session configuration - # @return [Session] A session configured to resume the specified session - # @alpha - # - # @example - # session = ClaudeAgent.unstable_v2_resume_session("session-abc123", model: "claude-sonnet-4-5-20250929") - # - def unstable_v2_resume_session(session_id, options) - # For resumption, we need to pass the resume option through - # Since SessionOptions doesn't have resume, we handle it in the Client options - session = V2Session.new(options) - session.instance_variable_set(:@resume_session_id, session_id) - - # Override build_client_options to include resume - session.define_singleton_method(:build_client_options) do - Options.new( - model: @options.model, - cli_path: @options.path_to_claude_code_executable, - env: @options.env, - allowed_tools: @options.allowed_tools, - disallowed_tools: @options.disallowed_tools, - can_use_tool: @options.can_use_tool, - hooks: @options.hooks, - permission_mode: @options.permission_mode, - resume: @resume_session_id - ) - end - - session - end - - # V2 API - UNSTABLE - # One-shot convenience function for single prompts. - # - # @param message [String] The prompt message - # @param options [Hash, SessionOptions] Session configuration - # @return [ResultMessage] The result of the query - # @alpha - # - # @example - # result = ClaudeAgent.unstable_v2_prompt("What files are here?", model: "claude-sonnet-4-5-20250929") - # - def unstable_v2_prompt(message, options) - session = unstable_v2_create_session(options) - begin - session.send(message) - result = nil - session.stream.each do |msg| - result = msg if msg.is_a?(ResultMessage) - end - result - ensure - session.close - end - end - end end