Skip to content

Commit 7350f2b

Browse files
authored
Merge pull request #62 from syou6162/subagent_stop
feat: SubagentStopフックのJSON出力対応
2 parents 01cffbf + 2d79aa2 commit 7350f2b

File tree

10 files changed

+1255
-103
lines changed

10 files changed

+1255
-103
lines changed

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,66 @@ Stop:
521521
decision: "block"
522522
reason: "Stopping Claude in this directory may lose work context"
523523
```
524+
525+
### SubagentStop JSON Output
526+
527+
SubagentStop hooks support JSON output format for Claude Code integration. Actions can return structured output with decision control to block or allow subagent stopping:
528+
529+
**Output Action** (type: `output`):
530+
```yaml
531+
SubagentStop:
532+
- actions:
533+
- type: output
534+
message: "SubagentStop reason message"
535+
decision: "block" # optional: "block" only; omit to allow stop
536+
reason: "Detailed reason for blocking" # required when decision is "block"
537+
```
538+
539+
**Command Action** (type: `command`):
540+
Commands must output JSON with the following structure:
541+
```json
542+
{
543+
"continue": true,
544+
"decision": "block",
545+
"reason": "Detailed reason for blocking",
546+
"stopReason": "Optional stop reason",
547+
"suppressOutput": false,
548+
"systemMessage": "Optional system message"
549+
}
550+
```
551+
552+
Note: To allow the subagent stop, omit the `decision` field entirely.
553+
554+
**Important**: SubagentStop uses the same decision control format as Stop hooks. It does NOT use `hookSpecificOutput`. All fields are at the top level.
555+
556+
**Field Merging**:
557+
When multiple actions execute:
558+
- `continue`: Always `true` (cannot be changed for SubagentStop)
559+
- `decision`: Last value wins (early return on `"block"`)
560+
- `reason`: Reset when decision changes; concatenated with newline within same decision
561+
- `systemMessage`: Concatenated with newline separator
562+
- `stopReason` and `suppressOutput`: Last value wins
563+
564+
**Exit Code Behavior**:
565+
SubagentStop hooks **always exit with code 0**. The `decision` field controls whether the subagent stopping is blocked:
566+
- `decision` field omitted: SubagentStop proceeds normally
567+
- `"block"`: SubagentStop is blocked (early return)
568+
569+
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).
570+
571+
**Example**:
572+
```yaml
573+
SubagentStop:
574+
- conditions:
575+
- type: cwd_contains
576+
value: "/important-project"
577+
actions:
578+
- type: output
579+
message: "Cannot stop subagent in important project directory"
580+
decision: "block"
581+
reason: "Stopping subagent in this directory may lose work context"
582+
```
583+
524584
### PostToolUse JSON Output
525585

526586
PostToolUse hooks support JSON output format for Claude Code integration. Actions can return structured output with decision control and additional context:

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,18 +627,18 @@ All conditions return proper error messages for unknown condition types, ensurin
627627
- Print message
628628
- Default `exit_status`:
629629
- 0 for SessionStart, SessionEnd, UserPromptSubmit (non-blocking events)
630-
- 2 for PostToolUse, SubagentStop, Notification, PreCompact
631-
- Note: PreToolUse and Stop use JSON output (exit_status ignored)
630+
- 2 for Notification, PreCompact
631+
- Note: PreToolUse, Stop, PostToolUse, and SubagentStop use JSON output (exit_status ignored)
632632

633633
### Exit Status Control
634634

635-
**JSON Output Events** (SessionStart, UserPromptSubmit, PreToolUse, Stop, PostToolUse):
635+
**JSON Output Events** (SessionStart, UserPromptSubmit, PreToolUse, Stop, SubagentStop, PostToolUse):
636636
- Always exit with code 0
637637
- Control behavior via JSON fields (`decision`, `permissionDecision`, etc.)
638638
- Errors logged to stderr as warnings
639639
- See CLAUDE.md for detailed JSON output format
640640

641-
**Legacy Exit Code Events** (SubagentStop, Notification, PreCompact, SessionEnd):
641+
**Legacy Exit Code Events** (Notification, PreCompact, SessionEnd):
642642
- 0
643643
- Success, allow execution, output to stdout
644644
- 2

executor.go

Lines changed: 175 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,18 +201,162 @@ func (e *ActionExecutor) ExecuteStopAction(action Action, input *StopInput, rawJ
201201

202202
// ExecuteSubagentStopAction executes an action for the SubagentStop event.
203203
// Command failures result in exit status 2 to block the subagent stop operation.
204-
func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *SubagentStopInput, rawJSON interface{}) error {
204+
func (e *ActionExecutor) ExecuteSubagentStopAction(action Action, input *SubagentStopInput, rawJSON interface{}) (*ActionOutput, error) {
205205
switch action.Type {
206206
case "command":
207207
cmd := unifiedTemplateReplace(action.Command, rawJSON)
208-
if err := e.runner.RunCommand(cmd, action.UseStdin, rawJSON); err != nil {
209-
// SubagentStopでコマンドが失敗した場合はexit 2でサブエージェント停止をブロック
210-
return NewExitError(2, fmt.Sprintf("Command failed: %v", err), true)
208+
stdout, stderr, exitCode, err := e.runner.RunCommandWithOutput(cmd, action.UseStdin, rawJSON)
209+
210+
// Command failed with non-zero exit code
211+
if exitCode != 0 {
212+
errMsg := fmt.Sprintf("Command failed with exit code %d: %s", exitCode, stderr)
213+
if strings.TrimSpace(stderr) == "" && err != nil {
214+
errMsg = fmt.Sprintf("Command failed with exit code %d: %v", exitCode, err)
215+
}
216+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
217+
return &ActionOutput{
218+
Continue: true,
219+
Decision: "block",
220+
Reason: errMsg,
221+
SystemMessage: errMsg,
222+
}, nil
211223
}
224+
225+
// Empty stdout - Allow subagent stop (validation-type CLI tools: silence = OK)
226+
if strings.TrimSpace(stdout) == "" {
227+
return &ActionOutput{
228+
Continue: true,
229+
Decision: "", // Allow subagent stop
230+
}, nil
231+
}
232+
233+
// Parse JSON output (SubagentStopOutput has no hookSpecificOutput, same schema as Stop)
234+
var cmdOutput SubagentStopOutput
235+
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {
236+
errMsg := fmt.Sprintf("Command output is not valid JSON: %s", stdout)
237+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
238+
return &ActionOutput{
239+
Continue: true,
240+
Decision: "block",
241+
Reason: errMsg,
242+
SystemMessage: errMsg,
243+
}, nil
244+
}
245+
246+
// Validate decision field (optional: "block" only, or field must be omitted entirely)
247+
decision := cmdOutput.Decision
248+
if decision != "" && decision != "block" {
249+
errMsg := "Invalid decision value: must be 'block' or field must be omitted entirely"
250+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
251+
return &ActionOutput{
252+
Continue: true,
253+
Decision: "block",
254+
Reason: errMsg,
255+
SystemMessage: errMsg,
256+
}, nil
257+
}
258+
259+
// Validate reason: required when decision is "block"
260+
if decision == "block" && cmdOutput.Reason == "" {
261+
errMsg := "Missing required field 'reason' when decision is 'block'"
262+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
263+
return &ActionOutput{
264+
Continue: true,
265+
Decision: "block",
266+
Reason: errMsg,
267+
SystemMessage: errMsg,
268+
}, nil
269+
}
270+
271+
// Check for unsupported fields and log warnings to stderr
272+
checkUnsupportedFieldsSubagentStop(stdout)
273+
274+
// Build ActionOutput from parsed JSON
275+
return &ActionOutput{
276+
Continue: true,
277+
Decision: decision,
278+
Reason: cmdOutput.Reason,
279+
StopReason: cmdOutput.StopReason,
280+
SuppressOutput: cmdOutput.SuppressOutput,
281+
SystemMessage: cmdOutput.SystemMessage,
282+
}, nil
283+
212284
case "output":
213-
return handleOutput(action.Message, action.ExitStatus, rawJSON)
285+
processedMessage := unifiedTemplateReplace(action.Message, rawJSON)
286+
287+
// Empty message check → fail-safe (decision: block)
288+
if strings.TrimSpace(processedMessage) == "" {
289+
errMsg := "Empty message in SubagentStop action"
290+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
291+
return &ActionOutput{
292+
Continue: true,
293+
Decision: "block",
294+
Reason: errMsg,
295+
SystemMessage: errMsg,
296+
}, nil
297+
}
298+
299+
// Warn if exit_status is set (deprecated for SubagentStop in JSON mode)
300+
if action.ExitStatus != nil {
301+
fmt.Fprintf(os.Stderr, "Warning: exit_status field is deprecated for SubagentStop hooks and will be ignored. Use 'decision' field instead.\n")
302+
}
303+
304+
// Validate action.Decision if set
305+
// Default to "" (allow subagent stop)
306+
decision := ""
307+
if action.Decision != nil {
308+
if *action.Decision != "" && *action.Decision != "block" {
309+
errMsg := "Invalid decision value in action config: must be 'block' or field must be omitted"
310+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
311+
return &ActionOutput{
312+
Continue: true,
313+
Decision: "block",
314+
Reason: processedMessage,
315+
SystemMessage: errMsg,
316+
}, nil
317+
}
318+
decision = *action.Decision
319+
}
320+
321+
// Determine reason with template expansion
322+
reason := processedMessage
323+
if action.Reason != nil {
324+
// Apply template expansion to reason
325+
reasonValue := unifiedTemplateReplace(*action.Reason, rawJSON)
326+
// Empty/whitespace reason with "block" decision should fallback to processedMessage
327+
if strings.TrimSpace(reasonValue) == "" && decision == "block" {
328+
reason = processedMessage
329+
} else {
330+
reason = reasonValue
331+
}
332+
}
333+
334+
// For allow (decision=""), clear reason (not applicable)
335+
if decision == "" {
336+
reason = ""
337+
}
338+
339+
// Final validation: decision "block" requires non-empty reason
340+
if decision == "block" && strings.TrimSpace(reason) == "" {
341+
errMsg := "Empty reason when decision is 'block' (reason is required for block)"
342+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
343+
return &ActionOutput{
344+
Continue: true,
345+
Decision: "block",
346+
Reason: errMsg,
347+
SystemMessage: errMsg,
348+
}, nil
349+
}
350+
351+
return &ActionOutput{
352+
Continue: true,
353+
Decision: decision,
354+
Reason: reason,
355+
SystemMessage: processedMessage,
356+
}, nil
214357
}
215-
return nil
358+
359+
return nil, nil
216360
}
217361

218362
// ExecutePreCompactAction executes an action for the PreCompact event.
@@ -984,6 +1128,31 @@ func checkUnsupportedFieldsStop(stdout string) {
9841128
}
9851129
}
9861130

1131+
// checkUnsupportedFieldsSubagentStop checks for unsupported fields in SubagentStop hook output
1132+
// SubagentStop uses the same schema as Stop (no hookSpecificOutput)
1133+
func checkUnsupportedFieldsSubagentStop(stdout string) {
1134+
var data map[string]interface{}
1135+
if err := json.Unmarshal([]byte(stdout), &data); err != nil {
1136+
return
1137+
}
1138+
1139+
supportedFields := map[string]bool{
1140+
"continue": true,
1141+
"decision": true, // SubagentStop specific (top-level, same as Stop)
1142+
"reason": true, // SubagentStop specific (top-level, same as Stop)
1143+
"stopReason": true,
1144+
"suppressOutput": true,
1145+
"systemMessage": true,
1146+
// Note: hookSpecificOutput is NOT supported for SubagentStop hooks
1147+
}
1148+
1149+
for field := range data {
1150+
if !supportedFields[field] {
1151+
fmt.Fprintf(os.Stderr, "Warning: Field '%s' is not supported for SubagentStop hooks\n", field)
1152+
}
1153+
}
1154+
}
1155+
9871156
// checkUnsupportedFieldsPostToolUse checks for unsupported fields in PostToolUse hook output
9881157
func checkUnsupportedFieldsPostToolUse(stdout string) {
9891158
var data map[string]interface{}

0 commit comments

Comments
 (0)