Skip to content

Commit 6602aae

Browse files
authored
Merge pull request #63 from syou6162/session_end_json
feat: SessionEnd JSON出力対応
2 parents 7350f2b + 4f715d8 commit 6602aae

File tree

12 files changed

+902
-374
lines changed

12 files changed

+902
-374
lines changed

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,66 @@ PostToolUse:
654654
reason: "Sensitive file modified - verify .gitignore configuration"
655655
```
656656

657+
### SessionEnd JSON Output
658+
659+
SessionEnd hooks support JSON output format for Claude Code integration. Actions can return structured output for session cleanup:
660+
661+
**Output Action** (type: `output`):
662+
```yaml
663+
SessionEnd:
664+
- actions:
665+
- type: output
666+
message: "Session cleanup message"
667+
```
668+
669+
**Command Action** (type: `command`):
670+
Commands must output JSON with the following structure:
671+
```json
672+
{
673+
"continue": true,
674+
"stopReason": "Optional stop reason",
675+
"suppressOutput": false,
676+
"systemMessage": "Optional system message"
677+
}
678+
```
679+
680+
**Important**: SessionEnd uses **Common JSON Fields only**. Unlike other events, it has no `decision`, `reason`, or `hookSpecificOutput` fields. SessionEnd is a cleanup-only hook and **cannot block** session termination.
681+
682+
**Field Merging**:
683+
When multiple actions execute:
684+
- `continue`: Always `true` (session end cannot be blocked)
685+
- `systemMessage`: Concatenated with newline separator
686+
- `stopReason` and `suppressOutput`: Last value wins
687+
688+
**Exit Code Behavior**:
689+
SessionEnd hooks **always exit with code 0**. The `continue` field is always `true` because session termination cannot be blocked.
690+
691+
Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. This ensures cleanup actions complete even on errors.
692+
693+
**Message Mapping**:
694+
Output action `message` field is mapped to `systemMessage` (shown to user, not to Claude). This maintains backward compatibility with the old stdout-based implementation.
695+
696+
**Condition Types**:
697+
SessionEnd supports the `reason_is` condition to match session end reasons:
698+
- `"clear"`: User cleared the session
699+
- `"logout"`: User logged out
700+
- `"prompt_input_exit"`: User exited via prompt input
701+
- `"other"`: Other reasons
702+
703+
**Example**:
704+
```yaml
705+
SessionEnd:
706+
- conditions:
707+
- type: reason_is
708+
value: "clear"
709+
actions:
710+
- type: output
711+
message: "Session cleared - workspace cleanup complete"
712+
- actions:
713+
- type: command
714+
command: "cleanup-session.sh" # Returns JSON with systemMessage
715+
```
716+
657717
## Common Workflows
658718

659719
### Adding a New Hook Type

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -626,19 +626,19 @@ All conditions return proper error messages for unknown condition types, ensurin
626626
- `output`
627627
- Print message
628628
- Default `exit_status`:
629-
- 0 for SessionStart, SessionEnd, UserPromptSubmit (non-blocking events)
629+
- 0 for SessionStart, UserPromptSubmit (non-blocking events)
630630
- 2 for Notification, PreCompact
631631
- 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, SubagentStop, PostToolUse):
635+
**JSON Output Events** (SessionStart, UserPromptSubmit, PreToolUse, Stop, SubagentStop, PostToolUse, SessionEnd):
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** (Notification, PreCompact, SessionEnd):
641+
**Legacy Exit Code Events** (Notification, PreCompact):
642642
- 0
643643
- Success, allow execution, output to stdout
644644
- 2

actions_test.go

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -288,76 +288,6 @@ func TestNewExitError(t *testing.T) {
288288
}
289289
}
290290

291-
func TestExecuteSessionEndAction_WithExitError(t *testing.T) {
292-
action := Action{
293-
Type: "output",
294-
Message: "SessionEnd error message",
295-
ExitStatus: intPtr(2),
296-
}
297-
298-
executor := NewActionExecutor(nil)
299-
err := executor.ExecuteSessionEndAction(action, &SessionEndInput{}, map[string]interface{}{})
300-
301-
if err == nil {
302-
t.Fatal("Expected ExitError, got nil")
303-
}
304-
305-
exitErr, ok := err.(*ExitError)
306-
if !ok {
307-
t.Fatalf("Expected *ExitError, got %T", err)
308-
}
309-
310-
if exitErr.Code != 2 {
311-
t.Errorf("Expected exit code 2, got %d", exitErr.Code)
312-
}
313-
314-
if !exitErr.Stderr {
315-
t.Error("Expected stderr output")
316-
}
317-
}
318-
319-
func TestExecuteSessionEndAction_OutputWithDefaultExitStatus(t *testing.T) {
320-
tests := []struct {
321-
name string
322-
exitStatus *int
323-
wantErr bool
324-
}{
325-
{
326-
name: "nil ExitStatus should print without error",
327-
exitStatus: nil,
328-
wantErr: false,
329-
},
330-
{
331-
name: "ExitStatus 0 should print without error",
332-
exitStatus: intPtr(0),
333-
wantErr: false,
334-
},
335-
}
336-
337-
for _, tt := range tests {
338-
t.Run(tt.name, func(t *testing.T) {
339-
action := Action{
340-
Type: "output",
341-
Message: "SessionEnd message",
342-
ExitStatus: tt.exitStatus,
343-
}
344-
345-
executor := NewActionExecutor(nil)
346-
err := executor.ExecuteSessionEndAction(action, &SessionEndInput{}, map[string]interface{}{})
347-
348-
if tt.wantErr {
349-
if err == nil {
350-
t.Error("Expected error, got nil")
351-
}
352-
} else {
353-
if err != nil {
354-
t.Errorf("Expected no error, got %v", err)
355-
}
356-
}
357-
})
358-
}
359-
}
360-
361291
func TestExecuteNotificationAction_CommandWithStubRunner(t *testing.T) {
362292
tests := []struct {
363293
name string

executor.go

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,25 +1007,85 @@ func (e *ActionExecutor) ExecutePostToolUseAction(action Action, input *PostTool
10071007
return nil, nil
10081008
}
10091009

1010-
// ExecuteSessionEndAction executes an action for the SessionEnd event.
1011-
// Errors are logged but do not block session end.
1012-
func (e *ActionExecutor) ExecuteSessionEndAction(action Action, input *SessionEndInput, rawJSON interface{}) error {
1010+
// ExecuteSessionEndAction executes an action for the SessionEnd event and returns ActionOutput.
1011+
// SessionEnd always returns continue=true (fail-safe: session end cannot be blocked).
1012+
// Errors are reported via systemMessage field, not by blocking execution.
1013+
func (e *ActionExecutor) ExecuteSessionEndAction(action Action, input *SessionEndInput, rawJSON interface{}) (*ActionOutput, error) {
10131014
switch action.Type {
10141015
case "command":
10151016
cmd := unifiedTemplateReplace(action.Command, rawJSON)
1016-
if err := e.runner.RunCommand(cmd, action.UseStdin, rawJSON); err != nil {
1017-
return err
1017+
stdout, stderr, exitCode, err := e.runner.RunCommandWithOutput(cmd, action.UseStdin, rawJSON)
1018+
1019+
// Command failed with non-zero exit code
1020+
if exitCode != 0 {
1021+
errMsg := fmt.Sprintf("Command failed with exit code %d: %s", exitCode, stderr)
1022+
if strings.TrimSpace(stderr) == "" && err != nil {
1023+
errMsg = fmt.Sprintf("Command failed with exit code %d: %v", exitCode, err)
1024+
}
1025+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
1026+
return &ActionOutput{
1027+
Continue: true,
1028+
SystemMessage: errMsg,
1029+
}, nil
10181030
}
1031+
1032+
// Empty stdout - Allow session end (validation-type CLI tools: silence = OK)
1033+
if strings.TrimSpace(stdout) == "" {
1034+
return &ActionOutput{
1035+
Continue: true,
1036+
}, nil
1037+
}
1038+
1039+
// Parse JSON output (SessionEndOutput has Common JSON Fields only)
1040+
var cmdOutput SessionEndOutput
1041+
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {
1042+
errMsg := fmt.Sprintf("Command output is not valid JSON: %s", stdout)
1043+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
1044+
return &ActionOutput{
1045+
Continue: true,
1046+
SystemMessage: errMsg,
1047+
}, nil
1048+
}
1049+
1050+
// Check for unsupported fields and log warnings to stderr
1051+
checkUnsupportedFieldsSessionEnd(stdout)
1052+
1053+
// Build ActionOutput from parsed JSON
1054+
return &ActionOutput{
1055+
Continue: true,
1056+
StopReason: cmdOutput.StopReason,
1057+
SuppressOutput: cmdOutput.SuppressOutput,
1058+
SystemMessage: cmdOutput.SystemMessage,
1059+
}, nil
1060+
10191061
case "output":
1020-
// SessionEndはブロッキング不要なので、exitStatusが指定されていない場合は通常出力
10211062
processedMessage := unifiedTemplateReplace(action.Message, rawJSON)
1022-
if action.ExitStatus != nil && *action.ExitStatus != 0 {
1023-
stderr := *action.ExitStatus == 2
1024-
return NewExitError(*action.ExitStatus, processedMessage, stderr)
1063+
1064+
// Emit warning if exit_status is set (backward compatibility - no longer used)
1065+
if action.ExitStatus != nil {
1066+
fmt.Fprintf(os.Stderr, "Warning: exit_status field is ignored in SessionEnd output actions (JSON output does not use exit codes)\n")
10251067
}
1026-
fmt.Println(processedMessage)
1068+
1069+
// Empty message is an error (fail-safe)
1070+
if strings.TrimSpace(processedMessage) == "" {
1071+
errMsg := "Empty message in SessionEnd action"
1072+
fmt.Fprintf(os.Stderr, "Warning: %s\n", errMsg)
1073+
return &ActionOutput{
1074+
Continue: true,
1075+
SystemMessage: errMsg,
1076+
}, nil
1077+
}
1078+
1079+
// Output action: message maps to systemMessage
1080+
return &ActionOutput{
1081+
Continue: true,
1082+
SystemMessage: processedMessage,
1083+
}, nil
10271084
}
1028-
return nil
1085+
1086+
return &ActionOutput{
1087+
Continue: true,
1088+
}, nil
10291089
}
10301090

10311091
// checkUnsupportedFieldsSessionStart checks for unsupported fields in SessionStart JSON output
@@ -1153,6 +1213,29 @@ func checkUnsupportedFieldsSubagentStop(stdout string) {
11531213
}
11541214
}
11551215

1216+
// checkUnsupportedFieldsSessionEnd checks for unsupported fields in SessionEnd hook output
1217+
// SessionEnd uses Common JSON Fields only (no decision/reason/hookSpecificOutput)
1218+
func checkUnsupportedFieldsSessionEnd(stdout string) {
1219+
var data map[string]interface{}
1220+
if err := json.Unmarshal([]byte(stdout), &data); err != nil {
1221+
return
1222+
}
1223+
1224+
supportedFields := map[string]bool{
1225+
"continue": true,
1226+
"stopReason": true,
1227+
"suppressOutput": true,
1228+
"systemMessage": true,
1229+
// Note: decision, reason, hookSpecificOutput are NOT supported for SessionEnd hooks
1230+
}
1231+
1232+
for field := range data {
1233+
if !supportedFields[field] {
1234+
fmt.Fprintf(os.Stderr, "Warning: Field '%s' is not supported for SessionEnd hooks\n", field)
1235+
}
1236+
}
1237+
}
1238+
11561239
// checkUnsupportedFieldsPostToolUse checks for unsupported fields in PostToolUse hook output
11571240
func checkUnsupportedFieldsPostToolUse(stdout string) {
11581241
var data map[string]interface{}

0 commit comments

Comments
 (0)