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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ Stop hooks **always exit with code 0**. The `decision` field controls whether Cl
- `decision` field omitted: Stop proceeds normally
- `"block"`: Stop is blocked (early return)

Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. On errors, `decision` defaults to `"block"` for safety (fail-safe).
Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. On errors, `decision` is omitted (allow stop) per the official Claude Code spec where hook errors are non-blocking.

**Backward Compatibility**:
Prior to JSON output support, Stop hooks used exit codes:
Expand Down Expand Up @@ -616,7 +616,7 @@ SubagentStop hooks **always exit with code 0**. The `decision` field controls wh
- `decision` field omitted: SubagentStop proceeds normally
- `"block"`: SubagentStop is blocked (early return)

Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. On errors, `decision` defaults to `"block"` for safety (fail-safe).
Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. On errors, `decision` is omitted (allow subagent stop) per the official Claude Code spec where hook errors are non-blocking.

**Example**:
```yaml
Expand Down
6 changes: 3 additions & 3 deletions actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@ func TestExecuteStopAction_CommandWithStubRunner(t *testing.T) {
wantDecision: "",
},
{
name: "command failure blocks stop with decision: block",
name: "command failure allows stop (decision empty)",
command: "exit 1",
stderr: "stop command failed",
exitCode: 1,
// Non-zero exit → fail-safe block
wantDecision: "block",
// Non-zero exit → allow stop (per official spec: hook errors are non-blocking)
wantDecision: "",
},
}

Expand Down
54 changes: 23 additions & 31 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
stdout, stderr, exitCode, err := e.runner.RunCommandWithOutput(cmd, action.UseStdin, rawJSON)

// Command failed with non-zero exit code
// Per official spec: hook errors are non-blocking → allow stop
if exitCode != 0 {
errMsg := fmt.Sprintf("Command failed with exit code %d: %s", exitCode, stderr)
if strings.TrimSpace(stderr) == "" && err != nil {
Expand All @@ -281,8 +282,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -302,8 +302,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -315,8 +314,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -327,8 +325,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -349,14 +346,14 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
case "output":
processedMessage := unifiedTemplateReplace(action.Message, rawJSON)

// Empty message check → fail-safe (decision: block)
// Empty message check → allow stop (error in systemMessage)
// Per official spec: hook errors are non-blocking → allow stop
if strings.TrimSpace(processedMessage) == "" {
errMsg := "Empty message in Stop action"
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -375,8 +372,7 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: processedMessage,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -402,13 +398,13 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
}

// Final validation: decision "block" requires non-empty reason
// Per official spec: hook errors are non-blocking → allow stop on validation error
if decision == "block" && strings.TrimSpace(reason) == "" {
errMsg := "Empty reason when decision is 'block' (reason is required for block)"
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -425,14 +421,15 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
}

// ExecuteSubagentStopAction executes an action for the SubagentStop event.
// Command failures result in exit status 2 to block the subagent stop operation.
// Per official Claude Code hooks spec: hook errors are non-blocking (allow subagent stop).
func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *SubagentStopInput, rawJSON any) (*ActionOutput, error) {
switch action.Type {
case "command":
cmd := unifiedTemplateReplace(action.Command, rawJSON)
stdout, stderr, exitCode, err := e.runner.RunCommandWithOutput(cmd, action.UseStdin, rawJSON)

// Command failed with non-zero exit code
// Per official spec: hook errors are non-blocking → allow subagent stop
if exitCode != 0 {
errMsg := fmt.Sprintf("Command failed with exit code %d: %s", exitCode, stderr)
if strings.TrimSpace(stderr) == "" && err != nil {
Expand All @@ -441,8 +438,7 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -462,8 +458,7 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -475,8 +470,7 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -487,8 +481,7 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -509,14 +502,14 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
case "output":
processedMessage := unifiedTemplateReplace(action.Message, rawJSON)

// Empty message check → fail-safe (decision: block)
// Empty message check → allow subagent stop (error in systemMessage)
// Per official spec: hook errors are non-blocking → allow subagent stop
if strings.TrimSpace(processedMessage) == "" {
errMsg := "Empty message in SubagentStop action"
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -535,8 +528,7 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: processedMessage,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand All @@ -562,13 +554,13 @@ func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *Subagen
}

// Final validation: decision "block" requires non-empty reason
// Per official spec: hook errors are non-blocking → allow subagent stop on validation error
if decision == "block" && strings.TrimSpace(reason) == "" {
errMsg := "Empty reason when decision is 'block' (reason is required for block)"
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
return &ActionOutput{
Continue: true,
Decision: "block",
Reason: errMsg,
Decision: "", // Allow subagent stop on error
SystemMessage: errMsg,
}, nil
}
Expand Down
Loading