diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc06255..c78204b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Every hook input carries `raw_input`**, the payload exactly as the CLI sent it. It used to be on `UnknownHookInput` only; a typed input copied the fields its class names and dropped the rest, so a field a newer CLI added was out of reach until the SDK learned it. Hook inputs are now built from the whole payload (every field the class declares, read from the same key as before). `#inspect` shows it on an `UnknownHookInput` only: a typed input already prints those fields as attributes. +- Hook input fields the CLI already sends (Python SDK #1331, plus the schemas of Claude Code 2.1.295): `prompt_id` and `effort` on every hook input; `duration_ms` on `PostToolUseHookInput` and `PostToolUseFailureHookInput`; `session_title` and `source` on `UserPromptSubmitHookInput`; `session_title`, `seconds_since_last_response`, `prompt_cache_likely_expired`, `context_tokens` and `estimated_cache_write_usd` on `SessionStartHookInput`; `parent_file_path` on `InstructionsLoadedHookInput`. +- Typed inputs for the hook events `PostToolBatch`, `UserPromptExpansion`, `PreModelSwitch`, `PostModelSwitch`, `DirectoryAdded` and `MessageDisplay` (`PostToolBatchHookInput`, ...), which used to arrive as `UnknownHookInput`. `HOOK_EVENTS` lists them, so it now has the CLI's 33 events. +- Hook-specific outputs from Python SDK #1331: `PostToolBatchHookSpecificOutput`, `UserPromptExpansionHookSpecificOutput`, `StopHookSpecificOutput`, `SubagentStopHookSpecificOutput`, `PreModelSwitchHookSpecificOutput`, `PostModelSwitchHookSpecificOutput`, `ElicitationHookSpecificOutput`, `ElicitationResultHookSpecificOutput`, `WorktreeCreateHookSpecificOutput` and `MessageDisplayHookSpecificOutput`; `session_title` and `suppress_original_prompt` on `UserPromptSubmitHookSpecificOutput`; `initial_user_message`, `session_title`, `watch_paths` and `reload_skills` on `SessionStartHookSpecificOutput`. A Hash return value could already send these keys; the typed classes rejected them with `ArgumentError`. `docs/hooks-and-permissions.md` now lists every output class and its fields. +- `InitMessage#capabilities`, `#plugin_errors`, `#terminal_slash_commands` and `#fast_mode_disabled_reason`, the init fields of Python SDK #1332 (`SystemInitData`) that `InitMessage` did not read. Test `capabilities` (for example for `"interrupt_receipt_v1"`) instead of comparing `claude_code_version`. + ### Changed - The repository moves to the **rubycatco** GitHub organization: `github.com/rubycatco/claude-agent-sdk-ruby`. The old `ya-luotao/...` URL redirects, and the gem on rubygems.org is unaffected; gem metadata, docs, templates and issue links now point at the new home. +- **`PermissionDeniedHookSpecificOutput#retry` defaults to `nil`** and is then left out of the output, as in Python; it used to default to `false` and always send `retry: false`, which the CLI reads the same way. Code that reads `#retry` back from a fresh instance gets `nil`. +- The `*HookSpecificOutput` classes are declared from one table of wire keys (`HookOutputKeys::HOOK_SPECIFIC`) instead of each writing its own `#to_h`. Each class still sends the same keys, except that a field set to `false` is now sent (only `nil` is left out); before, most fields also dropped `false`. - `CLIInstaller::PINNED_CLI_VERSION` moves from 2.1.288 to **2.1.295**, following the CLI the Python SDK bundles. `CLIInstaller.install_pinned` installs it. See the [Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md). ## [1.2.1] - 2026-10-06 diff --git a/README.md b/README.md index d988acf5..29168d57 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-ove - **Same wire protocol as the official SDKs.** Spawns the `claude` CLI as a subprocess and speaks stream-JSON over stdin/stdout, so every feature of the runtime is available: sessions, subagents, sandboxing, structured output, file checkpointing and rewind. - **`query()` for one-shot calls, `Client` for bidirectional sessions** with interrupts, mid-session model switching, and streaming input from any `Enumerator`. - **In-process custom tools.** Define tools as Ruby blocks; they run inside your process with direct access to your app state (SDK MCP servers), with JSON-Schema-validated arguments. -- **All 27 hook events and permission callbacks** with typed inputs, so you can gate, audit, or rewrite every tool call. +- **All 33 hook events and permission callbacks** with typed inputs, so you can gate, audit, or rewrite every tool call. - **Pluggable transport** to run the CLI somewhere else (an E2B microVM, a container, over SSH). ## Installation @@ -182,7 +182,7 @@ A String return is sent to Claude as a single text block. Return a Hash instead ### Hooks and permission callbacks -Hooks run your Ruby code at any of the 27 lifecycle events (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, `PreCompact`, …) with typed inputs. Permission callbacks decide programmatically whether a tool call may proceed. +Hooks run your Ruby code at any of the 33 lifecycle events (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, `PreCompact`, …) with typed inputs. Permission callbacks decide programmatically whether a tool call may proceed. ```ruby options = ClaudeAgentSDK::ClaudeAgentOptions.new( @@ -235,7 +235,7 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u | Bidirectional `Client` | ✅ | ✅ | ✅ | | Streaming input | `AsyncIterable` | `AsyncIterable` | `Enumerator` | | Custom tools (SDK MCP servers) | `tool()` | `@tool` decorator | `create_tool` block | -| Hooks (all 27 events) | ✅ | 10 typed, the rest by name | ✅ | +| Hooks (all 33 events) | ✅ | 10 typed, the rest by name | ✅ | | Permission callbacks | ✅ | ✅ | ✅ | | Structured output | ✅ | ✅ | ✅ | | All 28 message types | ✅ | partial | ✅ | diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 85f04617..7fcdeebc 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -6,12 +6,14 @@ A **hook** is a Ruby proc/lambda that the Claude Code *application* (*not* Claud ### Supported Events -All hook input objects include common fields like `session_id`, `transcript_path`, `cwd`, and `permission_mode`. +All hook input objects include the common fields `session_id`, `transcript_path`, `cwd`, `permission_mode`, `prompt_id` (the UUID of the prompt being processed, also the `prompt.id` of the CLI's OpenTelemetry events) and `effort` (the turn's reasoning effort, e.g. `{ level: "high" }`, on models that support it), plus `raw_input`: the payload exactly as the CLI sent it, Symbol keys included. A field the CLI sends that this SDK version has no reader for is still in `raw_input`. - `PreToolUse` → `PreToolUseHookInput` (`tool_name`, `tool_input`, `tool_use_id`) -- `PostToolUse` → `PostToolUseHookInput` (`tool_name`, `tool_input`, `tool_response`, `tool_use_id`) -- `PostToolUseFailure` → `PostToolUseFailureHookInput` (`tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt`) -- `UserPromptSubmit` → `UserPromptSubmitHookInput` (`prompt`) +- `PostToolUse` → `PostToolUseHookInput` (`tool_name`, `tool_input`, `tool_response`, `tool_use_id`, `duration_ms`) +- `PostToolUseFailure` → `PostToolUseFailureHookInput` (`tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt`, `duration_ms`) +- `PostToolBatch` → `PostToolBatchHookInput` (`tool_calls`) +- `UserPromptSubmit` → `UserPromptSubmitHookInput` (`prompt`, `session_title`, `source`) +- `UserPromptExpansion` → `UserPromptExpansionHookInput` (`expansion_type`, `command_name`, `command_args`, `command_source`, `prompt`) - `Stop` → `StopHookInput` (`stop_hook_active`, `last_assistant_message`, `background_tasks`, `session_crons`) - `SubagentStop` → `SubagentStopHookInput` (`stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`, `last_assistant_message`, `background_tasks`, `session_crons`) - `PreCompact` → `PreCompactHookInput` (`trigger`, `custom_instructions`) @@ -21,7 +23,7 @@ All hook input objects include common fields like `session_id`, `transcript_path `tool_input` (and the `input` a [permission callback](#permission-callbacks) receives) is the CLI's Hash passed through unchanged, so its keys are Symbols spelled as on the wire: `tool_input[:command]`, `input[:file_path]`. See [Hash keys](types.md#hash-keys). -All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types/hooks.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb). +All 33 hook events have typed input classes (an event this SDK version does not know arrives as an `UnknownHookInput`, with its payload in `raw_input`). See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types/hooks.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/rubycatco/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb). `background_tasks` and `session_crons` are optional arrays of raw CLI hashes. `nil` means the CLI did not provide a snapshot; `[]` means it provided an empty @@ -97,6 +99,33 @@ A callback returns a typed output (`AsyncHookJSONOutput`, or `SyncHookJSONOutput - The keys inside `updated_input`, `updated_tool_output`, `updated_mcp_tool_output` and a `PermissionRequest` `decision` are not renamed: those are the tool's own payloads (for `decision`, the CLI's), so spell them as the tool or the CLI does. - If one Hash spells the same field both ways, the CLI's spelling is the one sent (`permissionDecision` over `permission_decision`, `continue` over `continue_`). +The typed `hook_specific_output` classes, and the fields each one sends (a field left `nil` is not sent): + +| Class | Fields | +|-------|--------| +| `PreToolUseHookSpecificOutput` | `permission_decision`, `permission_decision_reason`, `updated_input`, `additional_context` | +| `PostToolUseHookSpecificOutput` | `additional_context`, `updated_tool_output`, `updated_mcp_tool_output` | +| `PostToolUseFailureHookSpecificOutput` | `additional_context` | +| `PostToolBatchHookSpecificOutput` | `additional_context` | +| `UserPromptSubmitHookSpecificOutput` | `additional_context`, `session_title`, `suppress_original_prompt` | +| `UserPromptExpansionHookSpecificOutput` | `additional_context`, `suppress_original_prompt` | +| `SessionStartHookSpecificOutput` | `additional_context`, `initial_user_message`, `session_title`, `watch_paths`, `reload_skills` | +| `SetupHookSpecificOutput` | `additional_context` | +| `NotificationHookSpecificOutput` | `additional_context` | +| `SubagentStartHookSpecificOutput` | `additional_context` | +| `StopHookSpecificOutput` | `additional_context` (feedback the model acts on; the conversation continues) | +| `SubagentStopHookSpecificOutput` | `additional_context` (the same, for the subagent) | +| `PermissionRequestHookSpecificOutput` | `decision` | +| `PermissionDeniedHookSpecificOutput` | `retry` | +| `PreModelSwitchHookSpecificOutput` | `permission_decision` (`"allow"` / `"deny"`), `permission_decision_reason` | +| `PostModelSwitchHookSpecificOutput` | `additional_context` | +| `ElicitationHookSpecificOutput` | `action` (`"accept"` / `"decline"` / `"cancel"`), `content` | +| `ElicitationResultHookSpecificOutput` | `action`, `content` | +| `CwdChangedHookSpecificOutput` | `watch_paths` | +| `FileChangedHookSpecificOutput` | `watch_paths` | +| `WorktreeCreateHookSpecificOutput` | `worktree_path` | +| `MessageDisplayHookSpecificOutput` | `display_content` (changes the screen only, not the stored message) | + ### Hook cancellation Dispatched hooks receive `HookContext#request_id` and `HookContext#signal`, using diff --git a/docs/types.md b/docs/types.md index e549b190..e50b1091 100644 --- a/docs/types.md +++ b/docs/types.md @@ -214,7 +214,7 @@ message types of their own. | Class | Wire type | Attributes | Notes | |-------|-----------|------------|-------| -| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Start of every turn, with the session as the CLI sees it (so a multi-query `Client` session receives one per query) | +| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `plugin_errors`, `slash_commands`, `terminal_slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state`, `fast_mode_disabled_reason`, `capabilities` (protocol features such as `"interrupt_receipt_v1"`: test it instead of `claude_code_version`) | Start of every turn, with the session as the CLI sees it (so a multi-query `Client` session receives one per query) | | `CompactBoundaryMessage` | `system` / `compact_boundary` | `uuid`, `session_id`, `compact_metadata` (a `CompactMetadata`: `pre_tokens`, `post_tokens`, `trigger`, `preserved_segment`, `custom_instructions`) | Context compaction completed | | `StatusMessage` | `system` / `status` | `uuid`, `session_id`, `status`, `permission_mode` | Compacting status, permission mode changes | | `APIRetryMessage` | `system` / `api_retry` | `uuid`, `session_id`, `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` | The CLI is retrying an API request | diff --git a/examples/lifecycle_hooks_example.rb b/examples/lifecycle_hooks_example.rb index e609d97d..c99ce7d1 100755 --- a/examples/lifecycle_hooks_example.rb +++ b/examples/lifecycle_hooks_example.rb @@ -7,7 +7,7 @@ # Example: Session and lifecycle hook events # -# The SDK supports 27 hook events. This example demonstrates the +# The SDK supports 33 hook events. This example demonstrates the # lifecycle hooks beyond the basic PreToolUse/PostToolUse covered # in hooks_example.rb and advanced_hooks_example.rb. diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index dac96a65..938c59d1 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -868,227 +868,55 @@ def untrack_callback_signal(request_id, signal) @callback_request_signals.delete(request_id) end - def parse_hook_input(input_data) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one branch per hook event type + # The typed input built for each hook event the SDK models; any other + # event becomes an UnknownHookInput. + HOOK_INPUT_CLASSES = { + 'PreToolUse' => PreToolUseHookInput, + 'PostToolUse' => PostToolUseHookInput, + 'PostToolUseFailure' => PostToolUseFailureHookInput, + 'UserPromptSubmit' => UserPromptSubmitHookInput, + 'Stop' => StopHookInput, + 'SubagentStop' => SubagentStopHookInput, + 'Notification' => NotificationHookInput, + 'SubagentStart' => SubagentStartHookInput, + 'PermissionRequest' => PermissionRequestHookInput, + 'PreCompact' => PreCompactHookInput, + 'SessionStart' => SessionStartHookInput, + 'SessionEnd' => SessionEndHookInput, + 'Setup' => SetupHookInput, + 'TeammateIdle' => TeammateIdleHookInput, + 'TaskCompleted' => TaskCompletedHookInput, + 'ConfigChange' => ConfigChangeHookInput, + 'WorktreeCreate' => WorktreeCreateHookInput, + 'WorktreeRemove' => WorktreeRemoveHookInput, + 'StopFailure' => StopFailureHookInput, + 'PostCompact' => PostCompactHookInput, + 'PermissionDenied' => PermissionDeniedHookInput, + 'TaskCreated' => TaskCreatedHookInput, + 'Elicitation' => ElicitationHookInput, + 'ElicitationResult' => ElicitationResultHookInput, + 'InstructionsLoaded' => InstructionsLoadedHookInput, + 'CwdChanged' => CwdChangedHookInput, + 'FileChanged' => FileChangedHookInput, + 'PostToolBatch' => PostToolBatchHookInput, + 'UserPromptExpansion' => UserPromptExpansionHookInput, + 'PreModelSwitch' => PreModelSwitchHookInput, + 'PostModelSwitch' => PostModelSwitchHookInput, + 'DirectoryAdded' => DirectoryAddedHookInput, + 'MessageDisplay' => MessageDisplayHookInput + }.freeze + private_constant :HOOK_INPUT_CLASSES + + # Every field of the payload that the class declares is assigned (the + # lenient parse path: other keys are skipped, not rejected), and the + # whole payload is kept as #raw_input, so a field the CLI added after this + # SDK version is still readable there. + def parse_hook_input(input_data) + input_data = {} unless input_data.is_a?(Hash) event_name = input_data[:hook_event_name] || input_data['hook_event_name'] - fetch = lambda do |key| - if input_data.key?(key) - input_data[key] - elsif input_data.key?(key.to_s) - input_data[key.to_s] - end - end - base_args = { - session_id: fetch.call(:session_id), - transcript_path: fetch.call(:transcript_path), - cwd: fetch.call(:cwd), - permission_mode: fetch.call(:permission_mode) - } - - # Subagent context fields shared by tool-lifecycle hooks - subagent_args = { - agent_id: fetch.call(:agent_id), - agent_type: fetch.call(:agent_type) - } - - case event_name - when 'PreToolUse' - PreToolUseHookInput.new( - tool_name: fetch.call(:tool_name), - tool_input: fetch.call(:tool_input), - tool_use_id: fetch.call(:tool_use_id), - **subagent_args, **base_args - ) - when 'PostToolUse' - PostToolUseHookInput.new( - tool_name: fetch.call(:tool_name), - tool_input: fetch.call(:tool_input), - tool_response: fetch.call(:tool_response), - tool_use_id: fetch.call(:tool_use_id), - **subagent_args, **base_args - ) - when 'PostToolUseFailure' - PostToolUseFailureHookInput.new( - tool_name: fetch.call(:tool_name), - tool_input: fetch.call(:tool_input), - tool_use_id: fetch.call(:tool_use_id), - error: fetch.call(:error), - is_interrupt: fetch.call(:is_interrupt), - **subagent_args, **base_args - ) - when 'UserPromptSubmit' - UserPromptSubmitHookInput.new( - prompt: fetch.call(:prompt), - **base_args - ) - when 'Stop' - StopHookInput.new( - stop_hook_active: fetch.call(:stop_hook_active), - last_assistant_message: fetch.call(:last_assistant_message), - background_tasks: fetch.call(:background_tasks), - session_crons: fetch.call(:session_crons), - **base_args - ) - when 'SubagentStop' - SubagentStopHookInput.new( - stop_hook_active: fetch.call(:stop_hook_active), - agent_id: fetch.call(:agent_id), - agent_transcript_path: fetch.call(:agent_transcript_path), - agent_type: fetch.call(:agent_type), - last_assistant_message: fetch.call(:last_assistant_message), - background_tasks: fetch.call(:background_tasks), - session_crons: fetch.call(:session_crons), - **base_args - ) - when 'Notification' - NotificationHookInput.new( - message: fetch.call(:message), - title: fetch.call(:title), - notification_type: fetch.call(:notification_type), - **base_args - ) - when 'SubagentStart' - SubagentStartHookInput.new( - agent_id: fetch.call(:agent_id), - agent_type: fetch.call(:agent_type), - **base_args - ) - when 'PermissionRequest' - PermissionRequestHookInput.new( - tool_name: fetch.call(:tool_name), - tool_input: fetch.call(:tool_input), - permission_suggestions: fetch.call(:permission_suggestions), - **subagent_args, **base_args - ) - when 'PreCompact' - PreCompactHookInput.new( - trigger: fetch.call(:trigger), - custom_instructions: fetch.call(:custom_instructions), - **base_args - ) - when 'SessionStart' - SessionStartHookInput.new( - source: fetch.call(:source), - agent_type: fetch.call(:agent_type), - model: fetch.call(:model), - **base_args - ) - when 'SessionEnd' - SessionEndHookInput.new( - reason: fetch.call(:reason), - **base_args - ) - when 'Setup' - SetupHookInput.new( - trigger: fetch.call(:trigger), - **base_args - ) - when 'TeammateIdle' - TeammateIdleHookInput.new( - teammate_name: fetch.call(:teammate_name), - team_name: fetch.call(:team_name), - **base_args - ) - when 'TaskCompleted' - TaskCompletedHookInput.new( - task_id: fetch.call(:task_id), - task_subject: fetch.call(:task_subject), - task_description: fetch.call(:task_description), - teammate_name: fetch.call(:teammate_name), - team_name: fetch.call(:team_name), - **base_args - ) - when 'ConfigChange' - ConfigChangeHookInput.new( - source: fetch.call(:source), - file_path: fetch.call(:file_path), - **base_args - ) - when 'WorktreeCreate' - WorktreeCreateHookInput.new( - name: fetch.call(:name), - **base_args - ) - when 'WorktreeRemove' - WorktreeRemoveHookInput.new( - worktree_path: fetch.call(:worktree_path), - **base_args - ) - when 'StopFailure' - StopFailureHookInput.new( - error: fetch.call(:error), - error_details: fetch.call(:error_details), - last_assistant_message: fetch.call(:last_assistant_message), - **base_args - ) - when 'PostCompact' - PostCompactHookInput.new( - trigger: fetch.call(:trigger), - compact_summary: fetch.call(:compact_summary), - **base_args - ) - when 'PermissionDenied' - PermissionDeniedHookInput.new( - tool_name: fetch.call(:tool_name), - tool_input: fetch.call(:tool_input), - tool_use_id: fetch.call(:tool_use_id), - reason: fetch.call(:reason), - **subagent_args, **base_args - ) - when 'TaskCreated' - TaskCreatedHookInput.new( - task_id: fetch.call(:task_id), - task_subject: fetch.call(:task_subject), - task_description: fetch.call(:task_description), - teammate_name: fetch.call(:teammate_name), - team_name: fetch.call(:team_name), - **base_args - ) - when 'Elicitation' - ElicitationHookInput.new( - mcp_server_name: fetch.call(:mcp_server_name), - message: fetch.call(:message), - mode: fetch.call(:mode), - url: fetch.call(:url), - elicitation_id: fetch.call(:elicitation_id), - requested_schema: fetch.call(:requested_schema), - **base_args - ) - when 'ElicitationResult' - ElicitationResultHookInput.new( - mcp_server_name: fetch.call(:mcp_server_name), - elicitation_id: fetch.call(:elicitation_id), - mode: fetch.call(:mode), - action: fetch.call(:action), - content: fetch.call(:content), - **base_args - ) - when 'InstructionsLoaded' - InstructionsLoadedHookInput.new( - file_path: fetch.call(:file_path), - memory_type: fetch.call(:memory_type), - load_reason: fetch.call(:load_reason), - globs: fetch.call(:globs), - trigger_file_path: fetch.call(:trigger_file_path), - **base_args - ) - when 'CwdChanged' - CwdChangedHookInput.new( - old_cwd: fetch.call(:old_cwd), - new_cwd: fetch.call(:new_cwd), - **base_args - ) - when 'FileChanged' - FileChangedHookInput.new( - file_path: fetch.call(:file_path), - event: fetch.call(:event), - **base_args - ) - else - # Unknown event: preserve the wire event name and full raw payload - # rather than dropping event-specific fields (Python passes the raw - # dict through, so nothing is lost there). - UnknownHookInput.new(hook_event_name: event_name, raw_input: input_data, **base_args) - end + hook_input = HOOK_INPUT_CLASSES.fetch(event_name, UnknownHookInput).from_hash(input_data) + hook_input.raw_input = input_data + hook_input end def handle_mcp_message(request_data) diff --git a/lib/claude_agent_sdk/types/hooks.rb b/lib/claude_agent_sdk/types/hooks.rb index 88bc6170..87d63a5f 100644 --- a/lib/claude_agent_sdk/types/hooks.rb +++ b/lib/claude_agent_sdk/types/hooks.rb @@ -8,8 +8,10 @@ module ClaudeAgentSDK PreToolUse PostToolUse PostToolUseFailure + PostToolBatch Notification UserPromptSubmit + UserPromptExpansion SessionStart SessionEnd Stop @@ -18,6 +20,8 @@ module ClaudeAgentSDK SubagentStop PreCompact PostCompact + PreModelSwitch + PostModelSwitch PermissionRequest PermissionDenied Setup @@ -32,6 +36,8 @@ module ClaudeAgentSDK InstructionsLoaded CwdChanged FileChanged + DirectoryAdded + MessageDisplay ].freeze # Hook matcher configuration @@ -54,10 +60,29 @@ class HookContext < Type attr_accessor :signal, :request_id end - # Base hook input with common fields + # Base hook input with common fields. + # + # `prompt_id` is the UUID of the user prompt being processed (the + # `prompt.id` of the CLI's OpenTelemetry events; absent before the first + # prompt). `effort` is the reasoning effort in effect for the turn, a Hash + # such as `{ level: "high" }`, sent on models that support effort. + # `raw_input` is the payload exactly as the CLI sent it (Symbol keys), so a + # field this SDK does not model yet can still be read. class BaseHookInput < Type - attr_accessor :session_id, :transcript_path, :cwd, :permission_mode + attr_accessor :session_id, :transcript_path, :cwd, :permission_mode, + :prompt_id, :effort, :raw_input attr_reader :hook_event_name + + private + + # A typed input already exposes the fields of its payload as attributes; + # repeating @raw_input would double the output. An UnknownHookInput keeps + # it, since @raw_input is the only place its payload lives. + def inspect_attributes + return super if is_a?(UnknownHookInput) + + super.reject { |pair| pair.first == 'raw_input' } + end end # PreToolUse hook input @@ -70,9 +95,11 @@ def initialize(attributes = {}) end end - # PostToolUse hook input + # PostToolUse hook input. `duration_ms` is the tool's execution time, + # without permission prompts and hooks. class PostToolUseHookInput < BaseHookInput - attr_accessor :tool_name, :tool_input, :tool_response, :tool_use_id, :agent_id, :agent_type + attr_accessor :tool_name, :tool_input, :tool_response, :tool_use_id, :agent_id, :agent_type, + :duration_ms def initialize(attributes = {}) super @@ -82,7 +109,7 @@ def initialize(attributes = {}) # UserPromptSubmit hook input class UserPromptSubmitHookInput < BaseHookInput - attr_accessor :prompt + attr_accessor :prompt, :session_title, :source def initialize(attributes = {}) super @@ -116,10 +143,10 @@ def initialize(attributes = {}) end end - # PostToolUseFailure hook input + # PostToolUseFailure hook input. `duration_ms` as on PostToolUseHookInput. class PostToolUseFailureHookInput < BaseHookInput attr_accessor :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt, - :agent_id, :agent_type + :agent_id, :agent_type, :duration_ms def initialize(attributes = {}) super @@ -167,9 +194,14 @@ def initialize(attributes = {}) end end - # SessionStart hook input + # SessionStart hook input. On "resume" and "fork", + # `seconds_since_last_response` is the time since the transcript's last + # assistant response and `prompt_cache_likely_expired` says whether that + # exceeds the prompt-cache TTL; `context_tokens` and + # `estimated_cache_write_usd` size the prompt the next request re-sends. class SessionStartHookInput < BaseHookInput - attr_accessor :source, :agent_type, :model + attr_accessor :source, :agent_type, :model, :session_title, :seconds_since_last_response, + :prompt_cache_likely_expired, :context_tokens, :estimated_cache_write_usd def initialize(attributes = {}) super @@ -310,7 +342,7 @@ def initialize(attributes = {}) # InstructionsLoaded hook input class InstructionsLoadedHookInput < BaseHookInput - attr_accessor :file_path, :memory_type, :load_reason, :globs, :trigger_file_path + attr_accessor :file_path, :memory_type, :load_reason, :globs, :trigger_file_path, :parent_file_path def initialize(attributes = {}) super @@ -338,64 +370,231 @@ def initialize(attributes = {}) end end - # Fallback for hook events the SDK does not yet model. Carries the wire - # event name and the complete raw payload so no fields are lost (Python - # passes hook input through as a raw dict, so unknown events lose - # nothing there). - class UnknownHookInput < BaseHookInput - attr_accessor :raw_input + # PostToolBatch hook input: fired once every tool call of a batch has + # resolved, before the next model request. `tool_calls` is an Array of + # `{ tool_name:, tool_input:, tool_use_id:, tool_response: }` Hashes. + class PostToolBatchHookInput < BaseHookInput + attr_accessor :tool_calls def initialize(attributes = {}) super - # Direct assignment: BaseHookInput exposes hook_event_name as - # attr_reader only, and Type#assign_attribute silently drops keys - # without public setters. - @hook_event_name = attributes[:hook_event_name] || attributes['hook_event_name'] + @hook_event_name = 'PostToolBatch' end end - # Setup hook specific output - class SetupHookSpecificOutput < Type - strict_attributes + # UserPromptExpansion hook input: a slash command or MCP prompt is about + # to be expanded. `expansion_type` is "slash_command" or "mcp_prompt". + class UserPromptExpansionHookInput < BaseHookInput + attr_accessor :expansion_type, :command_name, :command_args, :command_source, :prompt - attr_accessor :additional_context - attr_reader :hook_event_name + def initialize(attributes = {}) + super + @hook_event_name = 'UserPromptExpansion' + end + end + + # PreModelSwitch hook input. `requested_model` is what was asked for (an + # alias, a full id, or nil for the default); `from_model` and `to_model` + # are resolved ids. `context_tokens`, `prompt_cache_warm`, `cache_ttl` + # ("5m" or "1h") and `estimated_cache_write_usd` describe what switching + # costs; `pricing` says which rate the estimate used ("configured", + # "catalog" or "default"). + class PreModelSwitchHookInput < BaseHookInput + attr_accessor :from_model, :to_model, :requested_model, :source, + :context_tokens, :prompt_cache_warm, :cache_ttl, :estimated_cache_write_usd, :pricing def initialize(attributes = {}) super - @hook_event_name = 'Setup' + @hook_event_name = 'PreModelSwitch' end + end - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result + # PostModelSwitch hook input (the fields of PreModelSwitchHookInput) + class PostModelSwitchHookInput < BaseHookInput + attr_accessor :from_model, :to_model, :requested_model, :source, + :context_tokens, :prompt_cache_warm, :cache_ttl, :estimated_cache_write_usd, :pricing + + def initialize(attributes = {}) + super + @hook_event_name = 'PostModelSwitch' end end - # PreToolUse hook specific output - class PreToolUseHookSpecificOutput < Type - strict_attributes + # DirectoryAdded hook input. `directory` is absolute; `source` is + # "slash_command" (/add-dir) or "register_repo_root". + class DirectoryAddedHookInput < BaseHookInput + attr_accessor :directory, :source - attr_accessor :permission_decision, :permission_decision_reason, - :updated_input, :additional_context - attr_reader :hook_event_name + def initialize(attributes = {}) + super + @hook_event_name = 'DirectoryAdded' + end + end + + # MessageDisplay hook input: one flush of an assistant message on its way + # to the screen. `delta` holds the lines completed since the previous + # flush; `index` counts flushes from 0 and `final` marks the last one. + class MessageDisplayHookInput < BaseHookInput + attr_accessor :turn_id, :message_id, :index, :final, :delta def initialize(attributes = {}) super - @hook_event_name = 'PreToolUse' + @hook_event_name = 'MessageDisplay' end + end - def to_h - result = { hookEventName: @hook_event_name } - result[:permissionDecision] = @permission_decision if @permission_decision - result[:permissionDecisionReason] = @permission_decision_reason if @permission_decision_reason - result[:updatedInput] = @updated_input if @updated_input - result[:additionalContext] = @additional_context if @additional_context - result + # Fallback for hook events the SDK does not yet model. Carries the wire + # event name; the complete payload is in #raw_input, as on every hook + # input, so no field is lost (Python passes hook input through as a raw + # dict, so unknown events lose nothing there). + class UnknownHookInput < BaseHookInput + def initialize(attributes = {}) + super + # Direct assignment: BaseHookInput exposes hook_event_name as + # attr_reader only, and Type#assign_attribute silently drops keys + # without public setters. + @hook_event_name = attributes[:hook_event_name] || attributes['hook_event_name'] end end + # The spellings a hook callback's return value may use for a field of the + # typed output classes below, mapped to the key the CLI reads. A Hash a + # callback returns stands for the typed output with the same fields: its + # keys may be Symbols or Strings, the attribute names (snake_case) or what + # #to_h emits (camelCase), at the top level and inside hook_specific_output. + # + # Only names that differ from their wire key are listed. A key that is not + # listed is sent as written, so a field of a newer CLI that the typed + # classes do not model still gets through, in the CLI's own spelling. + # spec/unit/hook_output_normalization_spec.rb walks every typed output + # class and fails when an attribute and these tables disagree. + # + # @api private + module HookOutputKeys + # SyncHookJSONOutput and AsyncHookJSONOutput attributes, plus the + # Ruby-safe spellings of the two keywords. + TOP_LEVEL = { + 'continue_' => 'continue', + 'async_' => 'async', + 'suppress_output' => 'suppressOutput', + 'stop_reason' => 'stopReason', + 'system_message' => 'systemMessage', + 'hook_specific_output' => 'hookSpecificOutput', + 'async_timeout' => 'asyncTimeout' + }.freeze + + # Attributes of the *HookSpecificOutput classes, which take their wire + # keys from this table (HookSpecificOutputFields). + HOOK_SPECIFIC = { + 'hook_event_name' => 'hookEventName', + 'permission_decision' => 'permissionDecision', + 'permission_decision_reason' => 'permissionDecisionReason', + 'updated_input' => 'updatedInput', + 'additional_context' => 'additionalContext', + 'updated_tool_output' => 'updatedToolOutput', + 'updated_mcp_tool_output' => 'updatedMCPToolOutput', + 'watch_paths' => 'watchPaths', + 'session_title' => 'sessionTitle', + 'suppress_original_prompt' => 'suppressOriginalPrompt', + 'initial_user_message' => 'initialUserMessage', + 'reload_skills' => 'reloadSkills', + 'worktree_path' => 'worktreePath', + 'display_content' => 'displayContent' + }.freeze + + # The hook output Hash as the CLI reads it: String keys in wire + # spelling, at the top level and one level down, inside + # hookSpecificOutput. Values are never rewritten: updatedInput and the + # tool outputs are the tool's own payloads, and a PermissionRequest + # decision goes out as the caller wrote it. + def self.normalize(output) + normalized = rename(output, TOP_LEVEL) + specific = normalized['hookSpecificOutput'] + normalized['hookSpecificOutput'] = rename(specific, HOOK_SPECIFIC) if specific.is_a?(Hash) + normalized + end + + # Every key ends up as one String, so a Symbol and a String spelling the + # same field cannot both reach JSON.generate (json 3.x raises on that; + # 2.x emits the key twice). When a Hash carries both spellings of one + # field the wire spelling wins, whichever comes first; between two keys + # in the same spelling the later one does. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table.fetch(name, name) + if wire == name + wire_spelled[wire] = true + elsif wire_spelled.key?(wire) + next + end + renamed[wire] = value + end + renamed + end + private_class_method :rename + end + + # Completes a *HookSpecificOutput class, which declares its fields with + # attr_accessor and then names the event it answers. #to_h writes + # hookEventName and every one of those fields that is not nil, under the + # key HookOutputKeys::HOOK_SPECIFIC gives it (the attribute name when the + # table has no entry). + # + # @api private + module HookSpecificOutputFields + # The fields are the attributes the class has declared when this is + # called, so it goes after attr_accessor. + # + # @!macro [attach] hook_specific_output + # @!attribute [r] hook_event_name + # @return [String] "$1" + # @!method initialize(attributes = {}) + # Takes the fields by attribute name; an unknown one raises ArgumentError. + # @!method to_h + # The output as the CLI reads it: `hookEventName` and every field + # that is not nil, under its wire key. + # @return [Hash{Symbol => Object}] + def hook_specific_output(event) + strict_attributes + wire_keys = own_attribute_names.to_h do |name| + [:"@#{name}", HookOutputKeys::HOOK_SPECIFIC.fetch(name, name).to_sym] + end + attr_reader :hook_event_name + + define_method(:initialize) do |attributes = {}| + super(attributes) + @hook_event_name = event + end + define_method(:to_h) do + wire_keys.each_with_object({ hookEventName: @hook_event_name }) do |(ivar, key), result| + value = instance_variable_get(ivar) + result[key] = value unless value.nil? + end + end + end + end + + # Setup hook specific output + class SetupHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context + + hook_specific_output 'Setup' + end + + # PreToolUse hook specific output + class PreToolUseHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :permission_decision, :permission_decision_reason, :updated_input, :additional_context + + hook_specific_output 'PreToolUse' + end + # PostToolUse hook specific output. # # `updated_tool_output` (CLI 2.1.110+) replaces the tool's output entirely @@ -404,195 +603,204 @@ def to_h # honors it, so both are emitted when set. Mirrors Python's # `PostToolUseHookSpecificOutput`. class PostToolUseHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields - attr_accessor :additional_context, :updated_mcp_tool_output, :updated_tool_output - attr_reader :hook_event_name - - def initialize(attributes = {}) - super - @hook_event_name = 'PostToolUse' - end + attr_accessor :additional_context, :updated_tool_output, :updated_mcp_tool_output - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result[:updatedToolOutput] = @updated_tool_output unless @updated_tool_output.nil? - result[:updatedMCPToolOutput] = @updated_mcp_tool_output if @updated_mcp_tool_output - result - end + hook_specific_output 'PostToolUse' end # PostToolUseFailure hook specific output class PostToolUseFailureHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :additional_context - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'PostToolUseFailure' - end + hook_specific_output 'PostToolUseFailure' + end - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result - end + # PostToolBatch hook specific output + class PostToolBatchHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context + + hook_specific_output 'PostToolBatch' end - # UserPromptSubmit hook specific output + # UserPromptSubmit hook specific output. `session_title` sets the session + # title; `suppress_original_prompt` leaves the prompt out of the block + # message when the hook's decision is "block". class UserPromptSubmitHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields - attr_accessor :additional_context - attr_reader :hook_event_name + attr_accessor :additional_context, :session_title, :suppress_original_prompt - def initialize(attributes = {}) - super - @hook_event_name = 'UserPromptSubmit' - end + hook_specific_output 'UserPromptSubmit' + end - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result - end + # UserPromptExpansion hook specific output (`suppress_original_prompt` as + # on UserPromptSubmitHookSpecificOutput) + class UserPromptExpansionHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context, :suppress_original_prompt + + hook_specific_output 'UserPromptExpansion' end # Notification hook specific output class NotificationHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :additional_context - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'Notification' - end - - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result - end + hook_specific_output 'Notification' end # SubagentStart hook specific output class SubagentStartHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :additional_context - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'SubagentStart' - end + hook_specific_output 'SubagentStart' + end - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result - end + # Stop hook specific output. `additional_context` is feedback for the + # model, not an error: the conversation continues so it can act on it. + class StopHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context + + hook_specific_output 'Stop' + end + + # SubagentStop hook specific output (`additional_context` as on + # StopHookSpecificOutput, delivered to the subagent) + class SubagentStopHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context + + hook_specific_output 'SubagentStop' end # PermissionRequest hook specific output class PermissionRequestHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :decision - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'PermissionRequest' - end - - def to_h - result = { hookEventName: @hook_event_name } - result[:decision] = @decision if @decision - result - end + hook_specific_output 'PermissionRequest' end - # SessionStart hook specific output + # SessionStart hook specific output. `initial_user_message` becomes the + # session's first user message; `session_title` sets its title (ignored + # when the session starts from "clear" or "compact"); `watch_paths` are + # absolute paths to watch for FileChanged; `reload_skills` re-scans skill + # and command directories once SessionStart hooks finish. class SessionStartHookSpecificOutput < Type - strict_attributes - - attr_accessor :additional_context - attr_reader :hook_event_name + extend HookSpecificOutputFields - def initialize(attributes = {}) - super - @hook_event_name = 'SessionStart' - end + attr_accessor :additional_context, :initial_user_message, :session_title, :watch_paths, :reload_skills - def to_h - result = { hookEventName: @hook_event_name } - result[:additionalContext] = @additional_context if @additional_context - result - end + hook_specific_output 'SessionStart' end - # PermissionDenied hook specific output + # PermissionDenied hook specific output. `retry: true` tells the model it + # may retry the denied call (ignored for denials without a classifier + # verdict); left nil, the key is not sent, which the CLI reads as false. class PermissionDeniedHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :retry - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'PermissionDenied' - @retry = false if @retry.nil? - end + hook_specific_output 'PermissionDenied' + end - def to_h - result = { hookEventName: @hook_event_name } - result[:retry] = @retry unless @retry.nil? - result - end + # PreModelSwitch hook specific output. `permission_decision` "allow" + # proceeds and "deny" cancels the switch; "ask" is a refusal outside an + # interactive session. + class PreModelSwitchHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :permission_decision, :permission_decision_reason + + hook_specific_output 'PreModelSwitch' + end + + # PostModelSwitch hook specific output (`additional_context` reaches the + # model with the next request the new model serves) + class PostModelSwitchHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :additional_context + + hook_specific_output 'PostModelSwitch' + end + + # Elicitation hook specific output: answers an MCP elicitation request. + # `action` is "accept", "decline" or "cancel"; `content` holds the form + # values to submit with "accept". + class ElicitationHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :action, :content + + hook_specific_output 'Elicitation' + end + + # ElicitationResult hook specific output: overrides the action or content + # before the response reaches the MCP server. + class ElicitationResultHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :action, :content + + hook_specific_output 'ElicitationResult' end - # CwdChanged hook specific output + # CwdChanged hook specific output (`watch_paths` replaces the dynamic + # FileChanged watch list) class CwdChangedHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :watch_paths - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'CwdChanged' - end - - def to_h - result = { hookEventName: @hook_event_name } - result[:watchPaths] = @watch_paths if @watch_paths - result - end + hook_specific_output 'CwdChanged' end - # FileChanged hook specific output + # FileChanged hook specific output (`watch_paths` as on + # CwdChangedHookSpecificOutput) class FileChangedHookSpecificOutput < Type - strict_attributes + extend HookSpecificOutputFields attr_accessor :watch_paths - attr_reader :hook_event_name - def initialize(attributes = {}) - super - @hook_event_name = 'FileChanged' - end + hook_specific_output 'FileChanged' + end - def to_h - result = { hookEventName: @hook_event_name } - result[:watchPaths] = @watch_paths if @watch_paths - result - end + # WorktreeCreate hook specific output (`worktree_path`: the absolute path + # of the created worktree) + class WorktreeCreateHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :worktree_path + + hook_specific_output 'WorktreeCreate' + end + + # MessageDisplay hook specific output. `display_content` replaces the text + # on screen only: the stored message and what the model sees are + # unchanged. Left nil, the original is displayed. + class MessageDisplayHookSpecificOutput < Type + extend HookSpecificOutputFields + + attr_accessor :display_content + + hook_specific_output 'MessageDisplay' end # Async hook JSON output @@ -637,77 +845,4 @@ def to_h result end end - - # The spellings a hook callback's return value may use for a field of the - # typed output classes above, mapped to the key the CLI reads. A Hash a - # callback returns stands for the typed output with the same fields: its - # keys may be Symbols or Strings, the attribute names (snake_case) or what - # #to_h emits (camelCase), at the top level and inside hook_specific_output. - # - # Only names that differ from their wire key are listed. A key that is not - # listed is sent as written, so a field of a newer CLI that the typed - # classes do not model still gets through, in the CLI's own spelling. - # spec/unit/hook_output_normalization_spec.rb walks every typed output - # class and fails when an attribute and these tables disagree. - # - # @api private - module HookOutputKeys - # SyncHookJSONOutput and AsyncHookJSONOutput attributes, plus the - # Ruby-safe spellings of the two keywords. - TOP_LEVEL = { - 'continue_' => 'continue', - 'async_' => 'async', - 'suppress_output' => 'suppressOutput', - 'stop_reason' => 'stopReason', - 'system_message' => 'systemMessage', - 'hook_specific_output' => 'hookSpecificOutput', - 'async_timeout' => 'asyncTimeout' - }.freeze - - # Attributes of the *HookSpecificOutput classes. - HOOK_SPECIFIC = { - 'hook_event_name' => 'hookEventName', - 'permission_decision' => 'permissionDecision', - 'permission_decision_reason' => 'permissionDecisionReason', - 'updated_input' => 'updatedInput', - 'additional_context' => 'additionalContext', - 'updated_tool_output' => 'updatedToolOutput', - 'updated_mcp_tool_output' => 'updatedMCPToolOutput', - 'watch_paths' => 'watchPaths' - }.freeze - - # The hook output Hash as the CLI reads it: String keys in wire - # spelling, at the top level and one level down, inside - # hookSpecificOutput. Values are never rewritten: updatedInput and the - # tool outputs are the tool's own payloads, and a PermissionRequest - # decision goes out as the caller wrote it. - def self.normalize(output) - normalized = rename(output, TOP_LEVEL) - specific = normalized['hookSpecificOutput'] - normalized['hookSpecificOutput'] = rename(specific, HOOK_SPECIFIC) if specific.is_a?(Hash) - normalized - end - - # Every key ends up as one String, so a Symbol and a String spelling the - # same field cannot both reach JSON.generate (json 3.x raises on that; - # 2.x emits the key twice). When a Hash carries both spellings of one - # field the wire spelling wins, whichever comes first; between two keys - # in the same spelling the later one does. - def self.rename(hash, table) - renamed = {} - wire_spelled = {} - hash.each do |key, value| - name = key.to_s - wire = table.fetch(name, name) - if wire == name - wire_spelled[wire] = true - elsif wire_spelled.key?(wire) - next - end - renamed[wire] = value - end - renamed - end - private_class_method :rename - end end diff --git a/lib/claude_agent_sdk/types/messages.rb b/lib/claude_agent_sdk/types/messages.rb index b76b38d8..f18b8d0f 100644 --- a/lib/claude_agent_sdk/types/messages.rb +++ b/lib/claude_agent_sdk/types/messages.rb @@ -128,12 +128,23 @@ def inspect_attributes end end - # Init system message (emitted at session start and after /clear) + # Init system message (emitted at session start and after /clear). + # + # `capabilities` lists the protocol features the CLI supports (e.g. + # "interrupt_receipt_v1"; an open set, absent before CLI 2.1.205): test it + # for a feature instead of comparing `claude_code_version`. + # `plugin_errors` lists plugins that failed to load (`{ plugin:, type:, + # message:, path: }`; the CLI omits it when none failed, and older CLIs never + # send it). `terminal_slash_commands` are the entries of `slash_commands` + # bound to the local terminal (e.g. "exit"), sent only when there are some. + # `fast_mode_disabled_reason` names the check that keeps fast mode off. class InitMessage < SystemMessage attr_accessor :uuid, :session_id, :agents, :api_key_source, :betas, :claude_code_version, :cwd, :tools, :mcp_servers, :model, - :permission_mode, :slash_commands, :output_style, :skills, :plugins, - :fast_mode_state # "off", "cooldown", or "on" + :permission_mode, :slash_commands, :terminal_slash_commands, + :output_style, :skills, :plugins, :plugin_errors, + :fast_mode_state, # "off", "cooldown", or "on" + :fast_mode_disabled_reason, :capabilities end # Compact boundary system message (emitted after context compaction completes) diff --git a/plugins/claude-agent-ruby/.claude-plugin/plugin.json b/plugins/claude-agent-ruby/.claude-plugin/plugin.json index 8bd9b436..572cd3fa 100644 --- a/plugins/claude-agent-ruby/.claude-plugin/plugin.json +++ b/plugins/claude-agent-ruby/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "claude-agent-ruby", "version": "1.2.1", - "description": "Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 27 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, sandbox settings, session management, Rails integration, and custom transports.", + "description": "Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 33 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, sandbox settings, session management, Rails integration, and custom transports.", "author": { "name": "ya-luotao", "email": "ya.luotao@gmail.com" diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md index 951e2eeb..a8fceeae 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md @@ -1,6 +1,6 @@ --- name: claude-agent-ruby -description: Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 27 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, full sandbox settings (network + filesystem), all 28 message types (including tool_progress, auth_status, prompt_suggestion, hook lifecycle, compact_boundary, session_state_changed, mirror_error, task_updated), session browsing/mutations, SessionStore transcript mirroring to external storage (S3/Redis/Postgres) with store-backed resume, subagents, file checkpointing, Rails integration, and custom transports. Use this skill whenever the user mentions claude-agent-sdk, Claude Agent Ruby, building AI agents in Ruby, or integrating Claude Code into a Ruby/Rails application. +description: Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 33 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, full sandbox settings (network + filesystem), all 28 message types (including tool_progress, auth_status, prompt_suggestion, hook lifecycle, compact_boundary, session_state_changed, mirror_error, task_updated), session browsing/mutations, SessionStore transcript mirroring to external storage (S3/Redis/Postgres) with store-backed resume, subagents, file checkpointing, Rails integration, and custom transports. Use this skill whenever the user mentions claude-agent-sdk, Claude Agent Ruby, building AI agents in Ruby, or integrating Claude Code into a Ruby/Rails application. --- # Claude Agent Ruby SDK @@ -41,12 +41,14 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau - Use `output_format` for JSON schema structured output - Use `thinking: ThinkingConfigAdaptive.new` and control depth with `effort:`. `ThinkingConfigEnabled(budget_tokens:)` is only for older models that take a fixed budget; `ThinkingConfigDisabled` turns thinking off. -## Hooks (27 events) -All hook events: PreToolUse, PostToolUse, PostToolUseFailure, Notification, UserPromptSubmit, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PermissionRequest, PermissionDenied, Setup, TeammateIdle, TaskCreated, TaskCompleted, Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged. +## Hooks (33 events) +All hook events: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, UserPromptSubmit, UserPromptExpansion, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PreModelSwitch, PostModelSwitch, PermissionRequest, PermissionDenied, Setup, TeammateIdle, TaskCreated, TaskCompleted, Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged, DirectoryAdded, MessageDisplay. + +Every hook input also keeps the payload exactly as the CLI sent it in `raw_input` (Symbol keys), so a field this SDK version has no reader for is still readable; an event it does not know arrives as an `UnknownHookInput`. Define hooks as Ruby procs/lambdas. Do not combine `can_use_tool` with `permission_prompt_tool_name`. Tool-lifecycle hooks carry `agent_id` and `agent_type` when firing inside subagents. `StopHookInput` and `SubagentStopHookInput` include `last_assistant_message` plus optional `background_tasks` / `session_crons` snapshots of the **parent session's** background work (`nil` = not provided, `[]` = explicitly empty; not a registry of all agents). Every dispatched hook and `can_use_tool` callback gets `context.request_id` and `context.signal` (a `ClaudeAgentSDK::CancellationSignal`: `cancelled?` / `wait(timeout:)`) — cancellation is cooperative (CLI cancel, EOF, disconnect, callback failure, hook timeout), default `:thread` callbacks are never force-killed, and a decision returned after cancellation is discarded, never sent as an allow. -Hook-specific outputs with `to_h`: `PreToolUseHookSpecificOutput`, `PostToolUseHookSpecificOutput`, `PostToolUseFailureHookSpecificOutput`, `UserPromptSubmitHookSpecificOutput`, `NotificationHookSpecificOutput`, `SubagentStartHookSpecificOutput`, `SessionStartHookSpecificOutput`, `SetupHookSpecificOutput`, `PermissionRequestHookSpecificOutput`, `PermissionDeniedHookSpecificOutput`, `CwdChangedHookSpecificOutput`, `FileChangedHookSpecificOutput`. +Hook-specific outputs with `to_h` (fields per class: `docs/hooks-and-permissions.md`): `PreToolUseHookSpecificOutput`, `PostToolUseHookSpecificOutput`, `PostToolUseFailureHookSpecificOutput`, `PostToolBatchHookSpecificOutput`, `UserPromptSubmitHookSpecificOutput`, `UserPromptExpansionHookSpecificOutput`, `SessionStartHookSpecificOutput`, `SetupHookSpecificOutput`, `NotificationHookSpecificOutput`, `SubagentStartHookSpecificOutput`, `StopHookSpecificOutput`, `SubagentStopHookSpecificOutput`, `PermissionRequestHookSpecificOutput`, `PermissionDeniedHookSpecificOutput`, `PreModelSwitchHookSpecificOutput`, `PostModelSwitchHookSpecificOutput`, `ElicitationHookSpecificOutput`, `ElicitationResultHookSpecificOutput`, `CwdChangedHookSpecificOutput`, `FileChangedHookSpecificOutput`, `WorktreeCreateHookSpecificOutput`, `MessageDisplayHookSpecificOutput`. ## Bare Mode Use `bare: true` for minimal startup — skips hooks, LSP, plugin sync, CLAUDE.md auto-discovery, auto-memory, keychain reads. Explicitly provide context via `system_prompt`, `add_dirs`, `setting_sources`, `allowed_tools`. @@ -94,7 +96,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `/README.md` for the overview, install, and minimal API examples -- Read `/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (33 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md index dabcc6d7..0c3ee473 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md @@ -17,7 +17,7 @@ For everything else, open the matching `docs/` subpage: |-------|---------| | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | -| All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | +| All 33 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | | Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | | Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | @@ -264,7 +264,7 @@ ClaudeAgentSDK.tag_session(session_id: 'uuid', tag: 'important') ClaudeAgentSDK.tag_session(session_id: 'uuid', tag: nil) ``` -Hook with all 27 events example: +Hook with all 33 events example: ```ruby options = ClaudeAgentSDK::ClaudeAgentOptions.new( hooks: { diff --git a/sig/claude_agent_sdk/types/hooks.rbs b/sig/claude_agent_sdk/types/hooks.rbs index 19374e20..41b503e4 100644 --- a/sig/claude_agent_sdk/types/hooks.rbs +++ b/sig/claude_agent_sdk/types/hooks.rbs @@ -31,6 +31,12 @@ module ClaudeAgentSDK | InstructionsLoadedHookInput | CwdChangedHookInput | FileChangedHookInput + | PostToolBatchHookInput + | UserPromptExpansionHookInput + | PreModelSwitchHookInput + | PostModelSwitchHookInput + | DirectoryAddedHookInput + | MessageDisplayHookInput | UnknownHookInput # A *HookSpecificOutput (SyncHookJSONOutput#hook_specific_output). @@ -46,6 +52,16 @@ module ClaudeAgentSDK | PermissionDeniedHookSpecificOutput | CwdChangedHookSpecificOutput | FileChangedHookSpecificOutput + | PostToolBatchHookSpecificOutput + | UserPromptExpansionHookSpecificOutput + | StopHookSpecificOutput + | SubagentStopHookSpecificOutput + | PreModelSwitchHookSpecificOutput + | PostModelSwitchHookSpecificOutput + | ElicitationHookSpecificOutput + | ElicitationResultHookSpecificOutput + | WorktreeCreateHookSpecificOutput + | MessageDisplayHookSpecificOutput # What a hook callback returns: a Hash (Symbol or String keys, snake_case # or camelCase, also inside hook_specific_output), a typed output, or nil @@ -91,6 +107,18 @@ module ClaudeAgentSDK attr_accessor permission_mode: String? + # UUID of the user prompt being processed (the prompt.id of the CLI's + # OpenTelemetry events); nil before the first prompt. + attr_accessor prompt_id: String? + + # Reasoning effort in effect for the turn, e.g. { level: "high" }; sent + # on models that support effort. + attr_accessor effort: wire_hash? + + # The payload exactly as the CLI sent it (Symbol keys), including fields + # this SDK does not model. + attr_accessor raw_input: Hash[Symbol | String, untyped]? + # The event name, fixed by the subclass (read-only). attr_reader hook_event_name: String? end @@ -119,10 +147,17 @@ module ClaudeAgentSDK attr_accessor agent_id: String? attr_accessor agent_type: String? + + # Tool execution time, without permission prompts and hooks. + attr_accessor duration_ms: Integer? end class UserPromptSubmitHookInput < BaseHookInput attr_accessor prompt: String? + + attr_accessor session_title: String? + + attr_accessor source: String? end class StopHookInput < BaseHookInput @@ -165,6 +200,9 @@ module ClaudeAgentSDK attr_accessor agent_id: String? attr_accessor agent_type: String? + + # Tool execution time, without permission prompts and hooks. + attr_accessor duration_ms: Integer? end class NotificationHookInput < BaseHookInput @@ -205,6 +243,18 @@ module ClaudeAgentSDK attr_accessor agent_type: String? attr_accessor model: String? + + attr_accessor session_title: String? + + # resume/fork: seconds since the transcript's last assistant response. + attr_accessor seconds_since_last_response: Numeric? + + # resume/fork: whether that exceeds the prompt-cache TTL. + attr_accessor prompt_cache_likely_expired: bool? + + attr_accessor context_tokens: Integer? + + attr_accessor estimated_cache_write_usd: Numeric? end class SessionEndHookInput < BaseHookInput @@ -325,6 +375,8 @@ module ClaudeAgentSDK attr_accessor globs: Array[String]? attr_accessor trigger_file_path: String? + + attr_accessor parent_file_path: String? end class CwdChangedHookInput < BaseHookInput @@ -339,8 +391,90 @@ module ClaudeAgentSDK attr_accessor event: String? end + class PostToolBatchHookInput < BaseHookInput + # { tool_name:, tool_input:, tool_use_id:, tool_response: } wire_hashes. + attr_accessor tool_calls: Array[wire_hash]? + end + + class UserPromptExpansionHookInput < BaseHookInput + # "slash_command" or "mcp_prompt". + attr_accessor expansion_type: String? + + attr_accessor command_name: String? + + attr_accessor command_args: String? + + attr_accessor command_source: String? + + attr_accessor prompt: String? + end + + class PreModelSwitchHookInput < BaseHookInput + attr_accessor from_model: String? + + attr_accessor to_model: String? + + # An alias, a full id, or nil for the default. + attr_accessor requested_model: String? + + attr_accessor source: String? + + attr_accessor context_tokens: Integer? + + attr_accessor prompt_cache_warm: bool? + + # "5m" or "1h". + attr_accessor cache_ttl: String? + + attr_accessor estimated_cache_write_usd: Numeric? + + # The rate the estimate used: "configured", "catalog" or "default". + attr_accessor pricing: String? + end + + class PostModelSwitchHookInput < BaseHookInput + attr_accessor from_model: String? + + attr_accessor to_model: String? + + # An alias, a full id, or nil for the default. + attr_accessor requested_model: String? + + attr_accessor source: String? + + attr_accessor context_tokens: Integer? + + attr_accessor prompt_cache_warm: bool? + + # "5m" or "1h". + attr_accessor cache_ttl: String? + + attr_accessor estimated_cache_write_usd: Numeric? + + # The rate the estimate used: "configured", "catalog" or "default". + attr_accessor pricing: String? + end + + class DirectoryAddedHookInput < BaseHookInput + attr_accessor directory: String? + + # "slash_command" or "register_repo_root". + attr_accessor source: String? + end + + class MessageDisplayHookInput < BaseHookInput + attr_accessor turn_id: String? + + attr_accessor message_id: String? + + attr_accessor index: Integer? + + attr_accessor final: bool? + + attr_accessor delta: String? + end + class UnknownHookInput < BaseHookInput - attr_accessor raw_input: Hash[Symbol | String, untyped]? end # Hook outputs are built by the caller: keyword constructors (the Hash @@ -402,11 +536,16 @@ module ClaudeAgentSDK end class UserPromptSubmitHookSpecificOutput < Type - def initialize: (?additional_context: String?) -> void + def initialize: (?additional_context: String?, ?session_title: String?, ?suppress_original_prompt: bool?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void attr_accessor additional_context: String? + attr_accessor session_title: String? + + # With decision "block": leave the prompt out of the block message. + attr_accessor suppress_original_prompt: bool? + attr_reader hook_event_name: String def to_h: () -> Hash[Symbol, untyped] @@ -446,11 +585,23 @@ module ClaudeAgentSDK end class SessionStartHookSpecificOutput < Type - def initialize: (?additional_context: String?) -> void + def initialize: (?additional_context: String?, ?initial_user_message: String?, ?session_title: String?, ?watch_paths: Array[String]?, ?reload_skills: bool?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void attr_accessor additional_context: String? + # Becomes the session's first user message. + attr_accessor initial_user_message: String? + + # Ignored when the session starts from "clear" or "compact". + attr_accessor session_title: String? + + # Absolute paths to watch for FileChanged events. + attr_accessor watch_paths: Array[String]? + + # Re-scan skill and command directories once SessionStart hooks finish. + attr_accessor reload_skills: bool? + attr_reader hook_event_name: String def to_h: () -> Hash[Symbol, untyped] @@ -460,6 +611,7 @@ module ClaudeAgentSDK def initialize: (?retry: bool?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void + # nil (the default) sends no key, which the CLI reads as false. attr_accessor retry: bool? attr_reader hook_event_name: String @@ -489,6 +641,137 @@ module ClaudeAgentSDK def to_h: () -> Hash[Symbol, untyped] end + class PostToolBatchHookSpecificOutput < Type + def initialize: (?additional_context: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + attr_accessor additional_context: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + class UserPromptExpansionHookSpecificOutput < Type + def initialize: (?additional_context: String?, ?suppress_original_prompt: bool?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + attr_accessor additional_context: String? + + # With decision "block": leave the prompt out of the block message. + attr_accessor suppress_original_prompt: bool? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + # additional_context is feedback for the model; the conversation continues. + class StopHookSpecificOutput < Type + def initialize: (?additional_context: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + attr_accessor additional_context: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + # additional_context is feedback for the subagent; it continues. + class SubagentStopHookSpecificOutput < Type + def initialize: (?additional_context: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + attr_accessor additional_context: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + class PreModelSwitchHookSpecificOutput < Type + def initialize: (?permission_decision: String?, ?permission_decision_reason: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # "allow" proceeds, "deny" cancels; "ask" refuses outside an interactive session. + attr_accessor permission_decision: String? + + attr_accessor permission_decision_reason: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + class PostModelSwitchHookSpecificOutput < Type + def initialize: (?additional_context: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # Reaches the model with the next request the new model serves. + attr_accessor additional_context: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + # Answers an MCP elicitation request. + class ElicitationHookSpecificOutput < Type + def initialize: (?action: String?, ?content: Hash[Symbol | String, untyped]?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # "accept", "decline" or "cancel". + attr_accessor action: String? + + # Form values to submit with "accept". + attr_accessor content: Hash[Symbol | String, untyped]? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + # Overrides the action or content before the response reaches the MCP server. + class ElicitationResultHookSpecificOutput < Type + def initialize: (?action: String?, ?content: Hash[Symbol | String, untyped]?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # "accept", "decline" or "cancel". + attr_accessor action: String? + + attr_accessor content: Hash[Symbol | String, untyped]? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + class WorktreeCreateHookSpecificOutput < Type + def initialize: (?worktree_path: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # Absolute path of the created worktree. + attr_accessor worktree_path: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + + # Display only: the stored message and what the model sees are unchanged. + class MessageDisplayHookSpecificOutput < Type + def initialize: (?display_content: String?) -> void + | (Hash[Symbol | String, untyped]? attributes) -> void + + # Shown in place of the text; nil shows the original. + attr_accessor display_content: String? + + attr_reader hook_event_name: String + + def to_h: () -> Hash[Symbol, untyped] + end + # Defers the hook's decision (async: true by default). class AsyncHookJSONOutput < Type def initialize: (?async: bool?, ?async_timeout: Integer?) -> void diff --git a/sig/claude_agent_sdk/types/messages.rbs b/sig/claude_agent_sdk/types/messages.rbs index bb51c929..8d36f860 100644 --- a/sig/claude_agent_sdk/types/messages.rbs +++ b/sig/claude_agent_sdk/types/messages.rbs @@ -106,6 +106,10 @@ module ClaudeAgentSDK attr_accessor slash_commands: Array[String]? + # The entries of slash_commands bound to the local terminal (e.g. "exit"); + # sent only when there are some. + attr_accessor terminal_slash_commands: Array[String]? + attr_accessor output_style: String? attr_accessor skills: Array[String]? @@ -113,8 +117,19 @@ module ClaudeAgentSDK # { name:, path: } wire_hashes. attr_accessor plugins: Array[wire_hash]? + # Plugins that failed to load: { plugin:, type:, message:, path: } + # wire_hashes. Omitted when none failed (and by older CLIs). + attr_accessor plugin_errors: Array[wire_hash]? + # "off", "cooldown" or "on". attr_accessor fast_mode_state: String? + + # The check that keeps fast mode off (e.g. "not_first_party"); an open set. + attr_accessor fast_mode_disabled_reason: String? + + # Protocol features the CLI supports (e.g. "interrupt_receipt_v1"); an + # open set, absent before CLI 2.1.205. + attr_accessor capabilities: Array[String]? end # Emitted after context compaction completes. diff --git a/skills/SKILL.md b/skills/SKILL.md index 951e2eeb..a8fceeae 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -1,6 +1,6 @@ --- name: claude-agent-ruby -description: Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 27 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, full sandbox settings (network + filesystem), all 28 message types (including tool_progress, auth_status, prompt_suggestion, hook lifecycle, compact_boundary, session_state_changed, mirror_error, task_updated), session browsing/mutations, SessionStore transcript mirroring to external storage (S3/Redis/Postgres) with store-backed resume, subagents, file checkpointing, Rails integration, and custom transports. Use this skill whenever the user mentions claude-agent-sdk, Claude Agent Ruby, building AI agents in Ruby, or integrating Claude Code into a Ruby/Rails application. +description: Implement or modify Ruby code using the claude-agent-sdk gem. Covers query() one-shot calls, Client-based interactive sessions, streaming input, all 33 hook events, permission callbacks, SDK MCP servers, structured output, bare mode, full sandbox settings (network + filesystem), all 28 message types (including tool_progress, auth_status, prompt_suggestion, hook lifecycle, compact_boundary, session_state_changed, mirror_error, task_updated), session browsing/mutations, SessionStore transcript mirroring to external storage (S3/Redis/Postgres) with store-backed resume, subagents, file checkpointing, Rails integration, and custom transports. Use this skill whenever the user mentions claude-agent-sdk, Claude Agent Ruby, building AI agents in Ruby, or integrating Claude Code into a Ruby/Rails application. --- # Claude Agent Ruby SDK @@ -41,12 +41,14 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau - Use `output_format` for JSON schema structured output - Use `thinking: ThinkingConfigAdaptive.new` and control depth with `effort:`. `ThinkingConfigEnabled(budget_tokens:)` is only for older models that take a fixed budget; `ThinkingConfigDisabled` turns thinking off. -## Hooks (27 events) -All hook events: PreToolUse, PostToolUse, PostToolUseFailure, Notification, UserPromptSubmit, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PermissionRequest, PermissionDenied, Setup, TeammateIdle, TaskCreated, TaskCompleted, Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged. +## Hooks (33 events) +All hook events: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, UserPromptSubmit, UserPromptExpansion, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PreModelSwitch, PostModelSwitch, PermissionRequest, PermissionDenied, Setup, TeammateIdle, TaskCreated, TaskCompleted, Elicitation, ElicitationResult, ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged, DirectoryAdded, MessageDisplay. + +Every hook input also keeps the payload exactly as the CLI sent it in `raw_input` (Symbol keys), so a field this SDK version has no reader for is still readable; an event it does not know arrives as an `UnknownHookInput`. Define hooks as Ruby procs/lambdas. Do not combine `can_use_tool` with `permission_prompt_tool_name`. Tool-lifecycle hooks carry `agent_id` and `agent_type` when firing inside subagents. `StopHookInput` and `SubagentStopHookInput` include `last_assistant_message` plus optional `background_tasks` / `session_crons` snapshots of the **parent session's** background work (`nil` = not provided, `[]` = explicitly empty; not a registry of all agents). Every dispatched hook and `can_use_tool` callback gets `context.request_id` and `context.signal` (a `ClaudeAgentSDK::CancellationSignal`: `cancelled?` / `wait(timeout:)`) — cancellation is cooperative (CLI cancel, EOF, disconnect, callback failure, hook timeout), default `:thread` callbacks are never force-killed, and a decision returned after cancellation is discarded, never sent as an allow. -Hook-specific outputs with `to_h`: `PreToolUseHookSpecificOutput`, `PostToolUseHookSpecificOutput`, `PostToolUseFailureHookSpecificOutput`, `UserPromptSubmitHookSpecificOutput`, `NotificationHookSpecificOutput`, `SubagentStartHookSpecificOutput`, `SessionStartHookSpecificOutput`, `SetupHookSpecificOutput`, `PermissionRequestHookSpecificOutput`, `PermissionDeniedHookSpecificOutput`, `CwdChangedHookSpecificOutput`, `FileChangedHookSpecificOutput`. +Hook-specific outputs with `to_h` (fields per class: `docs/hooks-and-permissions.md`): `PreToolUseHookSpecificOutput`, `PostToolUseHookSpecificOutput`, `PostToolUseFailureHookSpecificOutput`, `PostToolBatchHookSpecificOutput`, `UserPromptSubmitHookSpecificOutput`, `UserPromptExpansionHookSpecificOutput`, `SessionStartHookSpecificOutput`, `SetupHookSpecificOutput`, `NotificationHookSpecificOutput`, `SubagentStartHookSpecificOutput`, `StopHookSpecificOutput`, `SubagentStopHookSpecificOutput`, `PermissionRequestHookSpecificOutput`, `PermissionDeniedHookSpecificOutput`, `PreModelSwitchHookSpecificOutput`, `PostModelSwitchHookSpecificOutput`, `ElicitationHookSpecificOutput`, `ElicitationResultHookSpecificOutput`, `CwdChangedHookSpecificOutput`, `FileChangedHookSpecificOutput`, `WorktreeCreateHookSpecificOutput`, `MessageDisplayHookSpecificOutput`. ## Bare Mode Use `bare: true` for minimal startup — skips hooks, LSP, plugin sync, CLAUDE.md auto-discovery, auto-memory, keychain reads. Explicitly provide context via `system_prompt`, `add_dirs`, `setting_sources`, `allowed_tools`. @@ -94,7 +96,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `/README.md` for the overview, install, and minimal API examples -- Read `/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (33 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/skills/references/usage-map.md b/skills/references/usage-map.md index dabcc6d7..0c3ee473 100644 --- a/skills/references/usage-map.md +++ b/skills/references/usage-map.md @@ -17,7 +17,7 @@ For everything else, open the matching `docs/` subpage: |-------|---------| | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | -| All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | +| All 33 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | | Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | | Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | @@ -264,7 +264,7 @@ ClaudeAgentSDK.tag_session(session_id: 'uuid', tag: 'important') ClaudeAgentSDK.tag_session(session_id: 'uuid', tag: nil) ``` -Hook with all 27 events example: +Hook with all 33 events example: ```ruby options = ClaudeAgentSDK::ClaudeAgentOptions.new( hooks: { diff --git a/spec/unit/hook_output_normalization_spec.rb b/spec/unit/hook_output_normalization_spec.rb index c96baa63..b3cf84c7 100644 --- a/spec/unit/hook_output_normalization_spec.rb +++ b/spec/unit/hook_output_normalization_spec.rb @@ -155,9 +155,20 @@ def on_the_wire(value) hook_event_name: %w[hookEventName PostToolUseFailure], additional_context: ['additionalContext', 'The registry is down; do not retry.'] }, + 'PostToolBatchHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostToolBatch], + additional_context: ['additionalContext', 'Two of the three writes touched generated files.'] + }, 'UserPromptSubmitHookSpecificOutput' => { hook_event_name: %w[hookEventName UserPromptSubmit], - additional_context: ['additionalContext', 'The user is on the staging cluster.'] + additional_context: ['additionalContext', 'The user is on the staging cluster.'], + session_title: ['sessionTitle', 'Staging rollout'], + suppress_original_prompt: ['suppressOriginalPrompt', true] + }, + 'UserPromptExpansionHookSpecificOutput' => { + hook_event_name: %w[hookEventName UserPromptExpansion], + additional_context: ['additionalContext', '/deploy targets staging by default.'], + suppress_original_prompt: ['suppressOriginalPrompt', false] }, 'NotificationHookSpecificOutput' => { hook_event_name: %w[hookEventName Notification], @@ -173,7 +184,46 @@ def on_the_wire(value) }, 'SessionStartHookSpecificOutput' => { hook_event_name: %w[hookEventName SessionStart], - additional_context: ['additionalContext', 'Branch: main, clean tree.'] + additional_context: ['additionalContext', 'Branch: main, clean tree.'], + initial_user_message: ['initialUserMessage', 'Summarize the open pull requests.'], + session_title: ['sessionTitle', 'PR triage'], + watch_paths: ['watchPaths', %w[/work/app/Gemfile.lock]], + reload_skills: ['reloadSkills', true] + }, + 'StopHookSpecificOutput' => { + hook_event_name: %w[hookEventName Stop], + additional_context: ['additionalContext', 'The changelog entry is still missing.'] + }, + 'SubagentStopHookSpecificOutput' => { + hook_event_name: %w[hookEventName SubagentStop], + additional_context: ['additionalContext', 'Also list the files you skipped.'] + }, + 'PreModelSwitchHookSpecificOutput' => { + hook_event_name: %w[hookEventName PreModelSwitch], + permission_decision: %w[permissionDecision deny], + permission_decision_reason: ['permissionDecisionReason', 'The prompt cache is warm.'] + }, + 'PostModelSwitchHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostModelSwitch], + additional_context: ['additionalContext', 'Keep answers short from here on.'] + }, + 'ElicitationHookSpecificOutput' => { + hook_event_name: %w[hookEventName Elicitation], + action: %w[action accept], + content: ['content', { environment: 'staging', confirm: true }] + }, + 'ElicitationResultHookSpecificOutput' => { + hook_event_name: %w[hookEventName ElicitationResult], + action: %w[action decline], + content: ['content', { reason: 'outside business hours' }] + }, + 'WorktreeCreateHookSpecificOutput' => { + hook_event_name: %w[hookEventName WorktreeCreate], + worktree_path: ['worktreePath', '/work/worktrees/fix-login'] + }, + 'MessageDisplayHookSpecificOutput' => { + hook_event_name: %w[hookEventName MessageDisplay], + display_content: ['displayContent', 'Deploying to [redacted]...'] }, 'PermissionDeniedHookSpecificOutput' => { hook_event_name: %w[hookEventName PermissionDenied], diff --git a/spec/unit/message_parser_spec.rb b/spec/unit/message_parser_spec.rb index 69d351d5..831cb21d 100644 --- a/spec/unit/message_parser_spec.rb +++ b/spec/unit/message_parser_spec.rb @@ -1342,6 +1342,28 @@ expect(msg).to be_a(ClaudeAgentSDK::InitMessage) expect(msg.fast_mode_state).to eq('cooldown') end + + # The init fields Python's SystemInitData (#1332) documents beyond the + # ones above, spelled as the CLI sends them. + it 'parses capabilities, plugin_errors, terminal_slash_commands and fast_mode_disabled_reason' do + plugin_error = { plugin: 'inline[0]', type: 'path-not-found', message: 'No such directory', + path: '/work/plugins/missing' } + data = { + type: 'system', subtype: 'init', uuid: 'u1', session_id: 's1', + slash_commands: %w[compact exit], terminal_slash_commands: %w[exit], + plugin_errors: [plugin_error], fast_mode_state: 'off', + fast_mode_disabled_reason: 'not_first_party', capabilities: %w[interrupt_receipt_v1] + } + + msg = described_class.parse(data) + + expect(msg).to be_an_instance_of(ClaudeAgentSDK::InitMessage) + expect(msg.capabilities).to eq(%w[interrupt_receipt_v1]) + expect(msg.plugin_errors).to eq([plugin_error]) + expect(msg.terminal_slash_commands).to eq(%w[exit]) + expect(msg.fast_mode_disabled_reason).to eq('not_first_party') + expect(msg.data).to eq(data) + end end context 'new top-level message types' do diff --git a/spec/unit/query_hook_input_spec.rb b/spec/unit/query_hook_input_spec.rb index 7281fc30..3857655f 100644 --- a/spec/unit/query_hook_input_spec.rb +++ b/spec/unit/query_hook_input_spec.rb @@ -5,7 +5,7 @@ # What a hook callback is handed, for every hook event: the typed input the # SDK builds from a `hook_callback` control request, checked reader by # reader. A field mapped from the wrong key, or dropped, used to pass the -# whole suite for 19 of the 27 events. +# whole suite for 19 of the 27 events the SDK had then. # # One row per event in ClaudeAgentSDK::HOOK_EVENTS. An event without a row, # and a reader its row gives no value to, both fail here — so the table has @@ -19,12 +19,18 @@ 'PreToolUse' => { tool_name: :string, tool_input: :object, tool_use_id: :string, agent_id: :string, agent_type: :string }, 'PostToolUse' => { tool_name: :string, tool_input: :object, tool_response: :object, tool_use_id: :string, - agent_id: :string, agent_type: :string }, + agent_id: :string, agent_type: :string, duration_ms: :integer }, 'PostToolUseFailure' => { tool_name: :string, tool_input: :object, tool_use_id: :string, error: :string, - is_interrupt: :boolean, agent_id: :string, agent_type: :string }, + is_interrupt: :boolean, agent_id: :string, agent_type: :string, + duration_ms: :integer }, + 'PostToolBatch' => { tool_calls: :objects }, 'Notification' => { message: :string, title: :string, notification_type: :string }, - 'UserPromptSubmit' => { prompt: :string }, - 'SessionStart' => { source: :string, agent_type: :string, model: :string }, + 'UserPromptSubmit' => { prompt: :string, session_title: :string, source: :string }, + 'UserPromptExpansion' => { expansion_type: :string, command_name: :string, command_args: :string, + command_source: :string, prompt: :string }, + 'SessionStart' => { source: :string, agent_type: :string, model: :string, session_title: :string, + seconds_since_last_response: :integer, prompt_cache_likely_expired: :boolean, + context_tokens: :integer, estimated_cache_write_usd: :number }, 'SessionEnd' => { reason: :string }, 'Stop' => { stop_hook_active: :boolean, last_assistant_message: :string, background_tasks: :objects, session_crons: :objects }, @@ -35,6 +41,12 @@ session_crons: :objects }, 'PreCompact' => { trigger: :string, custom_instructions: :string }, 'PostCompact' => { trigger: :string, compact_summary: :string }, + 'PreModelSwitch' => { from_model: :string, to_model: :string, requested_model: :string, source: :string, + context_tokens: :integer, prompt_cache_warm: :boolean, cache_ttl: :string, + estimated_cache_write_usd: :number, pricing: :string }, + 'PostModelSwitch' => { from_model: :string, to_model: :string, requested_model: :string, source: :string, + context_tokens: :integer, prompt_cache_warm: :boolean, cache_ttl: :string, + estimated_cache_write_usd: :number, pricing: :string }, 'PermissionRequest' => { tool_name: :string, tool_input: :object, permission_suggestions: :objects, agent_id: :string, agent_type: :string }, 'PermissionDenied' => { tool_name: :string, tool_input: :object, tool_use_id: :string, reason: :string, @@ -53,9 +65,12 @@ 'WorktreeCreate' => { name: :string }, 'WorktreeRemove' => { worktree_path: :string }, 'InstructionsLoaded' => { file_path: :string, memory_type: :string, load_reason: :string, globs: :strings, - trigger_file_path: :string }, + trigger_file_path: :string, parent_file_path: :string }, 'CwdChanged' => { old_cwd: :string, new_cwd: :string }, - 'FileChanged' => { file_path: :string, event: :string } + 'FileChanged' => { file_path: :string, event: :string }, + 'DirectoryAdded' => { directory: :string, source: :string }, + 'MessageDisplay' => { turn_id: :string, message_id: :string, index: :integer, final: :boolean, + delta: :string } }.freeze # A value of the field's JSON type that names the field, so no two fields @@ -67,17 +82,21 @@ def sentinel(event, field, type) when :strings then [marker] when :object then { marker: marker } when :objects then [{ marker: marker }] + when :integer then "#{field}#{event}".sum # a number of its own per field + when :number then "#{field}#{event}".sum + 0.25 when :boolean then true # the inputs default it to false or nil else raise ArgumentError, "unknown wire type #{type.inspect} for #{marker}" end end - # What every hook input carries. scratchpad_dir and prompt_id are on the - # frames CLI 2.1.286 sends; the typed inputs have no reader for them. + # What every hook input carries. scratchpad_dir is on the frames CLI + # 2.1.286 sends; the typed inputs have no reader for it, so it is only + # in #raw_input. def base_fields(event) { session_id: "session_id of #{event}", transcript_path: "transcript_path of #{event}", cwd: "cwd of #{event}", scratchpad_dir: "scratchpad_dir of #{event}", prompt_id: "prompt_id of #{event}", - permission_mode: "permission_mode of #{event}", hook_event_name: event } + permission_mode: "permission_mode of #{event}", effort: { level: "effort of #{event}" }, + hook_event_name: event } end # Registers one hook for +event+, sends it one hook_callback request the @@ -117,7 +136,8 @@ def dispatch_hook(event, input) expect(hook_input).to be_an_instance_of(input_class) # Every reader the class declares, not only the ones the row names: a # reader without a value in the frame is a row that fell behind. - input_class.attribute_names.each do |reader| + expect(hook_input.raw_input).to eq(input) + (input_class.attribute_names - ['raw_input']).each do |reader| expected = input.fetch(reader.to_sym) do raise "#{input_class} has a reader ##{reader} the #{event} row gives no value: add the wire field" end @@ -134,5 +154,18 @@ def dispatch_hook(event, input) expect(reply).to include('subtype' => 'success', 'request_id' => "request_id of #{event}") end end + + it 'hands an event this SDK does not model over as an UnknownHookInput with the whole frame' do + input = base_fields('FutureEvent').merge(future_field: { nested: [1, 2] }) + + calls, = dispatch_hook('FutureEvent', input) + + hook_input = calls.first[:input] + expect(hook_input).to be_an_instance_of(ClaudeAgentSDK::UnknownHookInput) + expect(hook_input.hook_event_name).to eq('FutureEvent') + expect(hook_input.prompt_id).to eq('prompt_id of FutureEvent') + expect(hook_input.effort).to eq(level: 'effort of FutureEvent') + expect(hook_input.raw_input).to eq(input) + end end end diff --git a/spec/unit/strict_attributes_spec.rb b/spec/unit/strict_attributes_spec.rb index 0e155753..151e80c9 100644 --- a/spec/unit/strict_attributes_spec.rb +++ b/spec/unit/strict_attributes_spec.rb @@ -36,6 +36,10 @@ def camelize(name) PostToolUseFailureHookSpecificOutput UserPromptSubmitHookSpecificOutput NotificationHookSpecificOutput SubagentStartHookSpecificOutput PermissionRequestHookSpecificOutput SessionStartHookSpecificOutput PermissionDeniedHookSpecificOutput CwdChangedHookSpecificOutput FileChangedHookSpecificOutput + PostToolBatchHookSpecificOutput UserPromptExpansionHookSpecificOutput StopHookSpecificOutput + SubagentStopHookSpecificOutput PreModelSwitchHookSpecificOutput PostModelSwitchHookSpecificOutput + ElicitationHookSpecificOutput ElicitationResultHookSpecificOutput WorktreeCreateHookSpecificOutput + MessageDisplayHookSpecificOutput AsyncHookJSONOutput SyncHookJSONOutput PermissionRuleValue PermissionUpdate PermissionResultAllow PermissionResultDeny ].freeze @@ -91,14 +95,29 @@ def camelize(name) additional_context: 'ctx', updated_mcp_tool_output: { ok: true }, updated_tool_output: 'out' }, 'PostToolUseFailureHookSpecificOutput' => { additional_context: 'ctx' }, - 'UserPromptSubmitHookSpecificOutput' => { additional_context: 'ctx' }, + 'UserPromptSubmitHookSpecificOutput' => { + additional_context: 'ctx', session_title: 'title', suppress_original_prompt: true + }, 'NotificationHookSpecificOutput' => { additional_context: 'ctx' }, 'SubagentStartHookSpecificOutput' => { additional_context: 'ctx' }, 'PermissionRequestHookSpecificOutput' => { decision: { behavior: 'allow' } }, - 'SessionStartHookSpecificOutput' => { additional_context: 'ctx' }, + 'SessionStartHookSpecificOutput' => { + additional_context: 'ctx', initial_user_message: 'hi', session_title: 'title', watch_paths: %w[/src], + reload_skills: true + }, 'PermissionDeniedHookSpecificOutput' => { retry: true }, 'CwdChangedHookSpecificOutput' => { watch_paths: %w[/src] }, 'FileChangedHookSpecificOutput' => { watch_paths: %w[/src] }, + 'PostToolBatchHookSpecificOutput' => { additional_context: 'ctx' }, + 'UserPromptExpansionHookSpecificOutput' => { additional_context: 'ctx', suppress_original_prompt: true }, + 'StopHookSpecificOutput' => { additional_context: 'ctx' }, + 'SubagentStopHookSpecificOutput' => { additional_context: 'ctx' }, + 'PreModelSwitchHookSpecificOutput' => { permission_decision: 'deny', permission_decision_reason: 'why' }, + 'PostModelSwitchHookSpecificOutput' => { additional_context: 'ctx' }, + 'ElicitationHookSpecificOutput' => { action: 'accept', content: { name: 'x' } }, + 'ElicitationResultHookSpecificOutput' => { action: 'decline', content: { name: 'x' } }, + 'WorktreeCreateHookSpecificOutput' => { worktree_path: '/tmp/wt' }, + 'MessageDisplayHookSpecificOutput' => { display_content: 'shown' }, 'AsyncHookJSONOutput' => { async: true, async_timeout: 1000 }, 'SyncHookJSONOutput' => { continue: false, suppress_output: true, stop_reason: 'done', decision: 'block', system_message: 'msg', diff --git a/spec/unit/types_spec.rb b/spec/unit/types_spec.rb index 2a73b86c..0881b66a 100644 --- a/spec/unit/types_spec.rb +++ b/spec/unit/types_spec.rb @@ -2520,6 +2520,34 @@ def []=(key, value) expect(hash[:hookEventName]).to eq('PermissionDenied') expect(hash[:retry]).to eq(true) end + + # The CLI reads a missing retry as false; Python leaves it out too. + it 'sends no retry unless one is set, and an explicit false as false' do + expect(described_class.new.retry).to be_nil + expect(described_class.new.to_h).to eq(hookEventName: 'PermissionDenied') + expect(described_class.new(retry: false).to_h).to eq(hookEventName: 'PermissionDenied', retry: false) + end + end + + describe ClaudeAgentSDK::HookSpecificOutputFields do + # Declared in the class body, where YARD (the published API reference) + # sees them; the helper only adds what is the same for every class. + it 'takes the fields from the attributes the class declares, in that order' do + output = ClaudeAgentSDK::SessionStartHookSpecificOutput.new( + reload_skills: false, watch_paths: %w[/src], session_title: 'Triage', additional_context: 'ctx' + ) + + expect(output.to_h.to_a).to eq( + [[:hookEventName, 'SessionStart'], [:additionalContext, 'ctx'], [:sessionTitle, 'Triage'], + [:watchPaths, %w[/src]], [:reloadSkills, false]] + ) + end + + it 'keeps hook_event_name out of reach of the constructor' do + output = ClaudeAgentSDK::StopHookSpecificOutput.new(hook_event_name: 'PreToolUse', additional_context: 'ctx') + + expect(output.to_h).to eq(hookEventName: 'Stop', additionalContext: 'ctx') + end end describe ClaudeAgentSDK::CwdChangedHookSpecificOutput do @@ -2989,6 +3017,27 @@ def []=(key, value) end end + describe ClaudeAgentSDK::BaseHookInput do + it 'omits the duplicate @raw_input from typed inputs such as PreToolUseHookInput' do + payload = { hook_event_name: 'PreToolUse', session_id: 'sess_1', tool_name: 'Bash', scratchpad_dir: '/tmp/s' } + input = ClaudeAgentSDK::PreToolUseHookInput.from_hash(payload) + input.raw_input = payload + + expect(input.inspect).to eq('#') + expect(input.raw_input).to include(scratchpad_dir: '/tmp/s') + end + + it 'keeps @raw_input in #inspect on an UnknownHookInput, where it is the only payload' do + payload = { hook_event_name: 'FutureEvent', detail: 'x' } + input = ClaudeAgentSDK::UnknownHookInput.from_hash(payload) + input.raw_input = payload + + expect(input.inspect).to eq('#') + end + end + describe ClaudeAgentSDK::ClaudeAgentOptions do it 'filters env values in #inspect without touching the options' do options = described_class.new(env: { 'ANTHROPIC_API_KEY' => 'sk-ant-secret', 'OTHER' => 'value' })