Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `StopFailure` hook event with `StopFailureInput` type (`error`, `error_details`, `last_assistant_message` fields) for handling API error-triggered stops (TypeScript SDK v0.2.80 parity)
- `on_stop_failure` DSL method on `HookRegistry` for the new event

## [0.7.17] - 2026-03-17

### Added
Expand Down
112 changes: 39 additions & 73 deletions SPEC.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ When both global and per-conversation hooks are set, they are merged additively

Each hook method accepts an optional first argument that filters which tool names trigger the callback. The matcher is passed as the first positional argument, before any keyword arguments.

| Matcher type | Behavior | Example |
|---|---|---|
| `nil` (omitted) | Catch-all, fires for every tool | `h.before_tool_use { \|i, c\| ... }` |
| `String` | Treated as a regex pattern | `h.before_tool_use("Bash") { \|i, c\| ... }` |
| `Regexp` | Normalized to its `source` string | `h.before_tool_use(/Bash\|Write/) { \|i, c\| ... }` |
| Matcher type | Behavior | Example |
|-----------------|-----------------------------------|-----------------------------------------------------|
| `nil` (omitted) | Catch-all, fires for every tool | `h.before_tool_use { \|i, c\| ... }` |
| `String` | Treated as a regex pattern | `h.before_tool_use("Bash") { \|i, c\| ... }` |
| `Regexp` | Normalized to its `source` string | `h.before_tool_use(/Bash\|Write/) { \|i, c\| ... }` |

A `Regexp` is converted to its `.source` string internally so it can be serialized over the control protocol. This means flags like `Regexp::IGNORECASE` are not preserved.

Expand Down Expand Up @@ -133,7 +133,7 @@ end

## Event Mapping Table

All 22 hook events with their Ruby DSL method, CLI event name, and description:
All 23 hook events with their Ruby DSL method, CLI event name, and description:

| Ruby method | CLI event | Description |
|--------------------------|----------------------|-------------------------------------------------|
Expand All @@ -145,6 +145,7 @@ All 22 hook events with their Ruby DSL method, CLI event name, and description:
| `on_session_start` | `SessionStart` | When a session begins. |
| `on_session_end` | `SessionEnd` | When a session ends. |
| `on_stop` | `Stop` | When the agent stops. |
| `on_stop_failure` | `StopFailure` | When the agent stops due to an API error. |
| `on_subagent_start` | `SubagentStart` | When a subagent is spawned. |
| `on_subagent_stop` | `SubagentStop` | When a subagent stops. |
| `before_compact` | `PreCompact` | Before context compaction. |
Expand Down Expand Up @@ -190,6 +191,7 @@ All input types inherit these fields from `BaseHookInput`:
| `SessionStart` | `SessionStartInput` | `source`, `agent_type`, `model` |
| `SessionEnd` | `SessionEndInput` | `reason` |
| `Stop` | `StopInput` | `stop_hook_active`, `last_assistant_message` |
| `StopFailure` | `StopFailureInput` | `error`, `error_details`, `last_assistant_message` |
| `SubagentStart` | `SubagentStartInput` | `agent_id`, `agent_type` |
| `SubagentStop` | `SubagentStopInput` | `stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`, `last_assistant_message` |
| `PreCompact` | `PreCompactInput` | `trigger`, `custom_instructions` |
Expand Down
24 changes: 23 additions & 1 deletion docs/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ end

## Message Types

22 message types, grouped by category.
24 message types, grouped by category.

### Conversation Messages

Expand Down Expand Up @@ -223,6 +223,28 @@ Methods:
- `trigger` -- compaction trigger type (`"manual"` or `"auto"`)
- `pre_tokens` -- token count before compaction

#### APIRetryMessage

Emitted when an API request fails with a retryable error and will be retried.

```ruby
APIRetryMessage = Data.define(:uuid, :session_id, :attempt, :max_retries, :retry_delay_ms, :error_status, :error)
```

| Field | Type | Default |
|------------------|----------------|---------|
| `uuid` | `String` | `""` |
| `session_id` | `String` | `""` |
| `attempt` | `Integer` | `0` |
| `max_retries` | `Integer` | `0` |
| `retry_delay_ms` | `Integer` | `0` |
| `error_status` | `Integer, nil` | `nil` |
| `error` | `String, nil` | `nil` |

Methods:

- `type` -- `:api_retry`

#### StatusMessage

Session status report (e.g., `"compacting"`).
Expand Down
1 change: 1 addition & 0 deletions lib/claude_agent/hook_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class HookRegistry
on_session_start: "SessionStart",
on_session_end: "SessionEnd",
on_stop: "Stop",
on_stop_failure: "StopFailure",
on_subagent_start: "SubagentStart",
on_subagent_stop: "SubagentStop",
before_compact: "PreCompact",
Expand Down
5 changes: 5 additions & 0 deletions lib/claude_agent/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module ClaudeAgent
SessionStart
SessionEnd
Stop
StopFailure
SubagentStart
SubagentStop
PreCompact
Expand Down Expand Up @@ -175,6 +176,10 @@ def initialize(#{params.join(", ")})
BaseHookInput.define_input "Stop",
optional: { stop_hook_active: false, last_assistant_message: nil }

BaseHookInput.define_input "StopFailure",
required: [ :error ],
optional: { error_details: nil, last_assistant_message: nil }

BaseHookInput.define_input "SubagentStart",
required: [ :agent_id, :agent_type ]

Expand Down
8 changes: 8 additions & 0 deletions sig/claude_agent.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,14 @@ module ClaudeAgent
def initialize: (?stop_hook_active: bool, ?last_assistant_message: String?, **untyped) -> void
end

class StopFailureInput < BaseHookInput
attr_reader error: String
attr_reader error_details: String?
attr_reader last_assistant_message: String?

def initialize: (error: String, ?error_details: String?, ?last_assistant_message: String?, **untyped) -> void
end

class SubagentStartInput < BaseHookInput
attr_reader agent_id: String
attr_reader agent_type: String
Expand Down
4 changes: 2 additions & 2 deletions test/claude_agent/test_hook_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ class TestClaudeAgentHookRegistry < ActiveSupport::TestCase
assert hooks.key?("Stop")
end

test "all 22 events are mapped" do
assert_equal 22, ClaudeAgent::HookRegistry::EVENT_MAP.size
test "all 23 events are mapped" do
assert_equal 23, ClaudeAgent::HookRegistry::EVENT_MAP.size
end

# --- Matcher normalization ---
Expand Down
27 changes: 26 additions & 1 deletion test/claude_agent/test_hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,31 @@ class TestClaudeAgentHooks < ActiveSupport::TestCase
assert_equal "I've completed the task.", input.last_assistant_message
end

# --- StopFailureInput ---

test "stop_failure_input" do
input = ClaudeAgent::StopFailureInput.new(error: "rate_limit")
assert_equal "StopFailure", input.hook_event_name
assert_equal "rate_limit", input.error
end

test "stop_failure_input_defaults" do
input = ClaudeAgent::StopFailureInput.new(error: "server_error")
assert_nil input.error_details
assert_nil input.last_assistant_message
end

test "stop_failure_input_with_all_fields" do
input = ClaudeAgent::StopFailureInput.new(
error: "rate_limit",
error_details: "Rate limit exceeded for model",
last_assistant_message: "I was working on..."
)
assert_equal "rate_limit", input.error
assert_equal "Rate limit exceeded for model", input.error_details
assert_equal "I was working on...", input.last_assistant_message
end

# --- SubagentStartInput ---

test "subagent_start_input" do
Expand Down Expand Up @@ -675,7 +700,7 @@ class TestClaudeAgentHooks < ActiveSupport::TestCase
assert ClaudeAgent::SetupInput < ClaudeAgent::BaseHookInput
end

test "define_input generates all 21 input classes" do
test "define_input generates all 22 input classes" do
ClaudeAgent::HOOK_EVENTS.each do |event|
klass = ClaudeAgent.const_get("#{event}Input")
assert klass < ClaudeAgent::BaseHookInput, "#{event}Input should inherit from BaseHookInput"
Expand Down
Loading