feat(03-01): HookEvent struct, processHookEvent mapping, UpdateFromHook, WaitType
- HookEvent struct parses Claude Code hook JSON payload - processHookEvent maps Notification/Stop/PostToolUse/PreToolUse to State+WaitType - UpdateFromHook creates new entries and manages WaitingSince transitions - SessionInfo.WaitType serialized in JSON with omitempty - 12 tests cover all event mappings, edge cases, and JSON serialization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
hook.go
Normal file
83
hook.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// HookEvent represents the JSON payload sent by Claude Code hooks.
|
||||||
|
type HookEvent struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TranscriptPath string `json:"transcript_path,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
HookEventName string `json:"hook_event_name"`
|
||||||
|
NotificationType string `json:"notification_type,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
LastAssistantMsg string `json:"last_assistant_message,omitempty"`
|
||||||
|
StopHookActive bool `json:"stop_hook_active,omitempty"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// processHookEvent maps a Claude Code hook event to a registry update.
|
||||||
|
// Ignores empty session IDs and unknown event types.
|
||||||
|
func (d *Daemon) processHookEvent(event HookEvent) {
|
||||||
|
if event.SessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state, waitType string
|
||||||
|
|
||||||
|
switch event.HookEventName {
|
||||||
|
case "Notification":
|
||||||
|
state = "Needs Input"
|
||||||
|
switch event.NotificationType {
|
||||||
|
case "permission_prompt":
|
||||||
|
waitType = "permission"
|
||||||
|
case "idle_prompt":
|
||||||
|
waitType = "idle"
|
||||||
|
default:
|
||||||
|
waitType = "question"
|
||||||
|
}
|
||||||
|
case "Stop":
|
||||||
|
state = "Needs Input"
|
||||||
|
waitType = "question"
|
||||||
|
case "PostToolUse", "PreToolUse":
|
||||||
|
state = "Working"
|
||||||
|
waitType = ""
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d.registry.UpdateFromHook(event.SessionID, state, waitType, event.Cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFromHook updates a session from a hook event.
|
||||||
|
// Creates the entry if it doesn't exist yet (hook can arrive before first poll).
|
||||||
|
func (r *SessionRegistry) UpdateFromHook(sessionID, state, waitType, cwd string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
existing, ok := r.sessions[sessionID]
|
||||||
|
if !ok {
|
||||||
|
existing = &TrackedSession{}
|
||||||
|
r.sessions[sessionID] = existing
|
||||||
|
}
|
||||||
|
|
||||||
|
prevState := existing.PrevState
|
||||||
|
existing.Info.SessionID = sessionID
|
||||||
|
existing.Info.State = state
|
||||||
|
existing.Info.WaitType = waitType
|
||||||
|
if cwd != "" {
|
||||||
|
existing.Info.Cwd = cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitingSince transition
|
||||||
|
isWaiting := state == "Needs Input"
|
||||||
|
wasWaiting := prevState == "Needs Input"
|
||||||
|
if isWaiting && !wasWaiting {
|
||||||
|
now := time.Now()
|
||||||
|
existing.Info.WaitingSince = &now
|
||||||
|
} else if !isWaiting {
|
||||||
|
existing.Info.WaitingSince = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.PrevState = state
|
||||||
|
}
|
||||||
227
hook_test.go
Normal file
227
hook_test.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessHookNotificationPermission(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "Notification",
|
||||||
|
NotificationType: "permission_prompt",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Needs Input" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Needs Input")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "permission" {
|
||||||
|
t.Errorf("WaitType = %q, want %q", list[0].WaitType, "permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookNotificationIdle(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "Notification",
|
||||||
|
NotificationType: "idle_prompt",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Needs Input" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Needs Input")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "idle" {
|
||||||
|
t.Errorf("WaitType = %q, want %q", list[0].WaitType, "idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookNotificationUnknown(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "Notification",
|
||||||
|
NotificationType: "some_unknown_type",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Needs Input" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Needs Input")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "question" {
|
||||||
|
t.Errorf("WaitType = %q, want %q", list[0].WaitType, "question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookStop(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "Stop",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Needs Input" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Needs Input")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "question" {
|
||||||
|
t.Errorf("WaitType = %q, want %q", list[0].WaitType, "question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookPostToolUse(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
// First set session to Needs Input
|
||||||
|
d.registry.UpdateFromHook("sess-1", "Needs Input", "permission", "/tmp")
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "PostToolUse",
|
||||||
|
ToolName: "Bash",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Working" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Working")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "" {
|
||||||
|
t.Errorf("WaitType = %q, want empty", list[0].WaitType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookPreToolUse(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "PreToolUse",
|
||||||
|
ToolName: "Read",
|
||||||
|
Cwd: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].State != "Working" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Working")
|
||||||
|
}
|
||||||
|
if list[0].WaitType != "" {
|
||||||
|
t.Errorf("WaitType = %q, want empty", list[0].WaitType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookIgnoresEmptySessionID(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "",
|
||||||
|
HookEventName: "Notification",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Errorf("registry len = %d, want 0 (empty session_id should be ignored)", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessHookIgnoresUnknownEvent(t *testing.T) {
|
||||||
|
d := newTestDaemon(t)
|
||||||
|
|
||||||
|
d.processHookEvent(HookEvent{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
HookEventName: "SomeUnknownEvent",
|
||||||
|
})
|
||||||
|
|
||||||
|
list := d.registry.List()
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Errorf("registry len = %d, want 0 (unknown event should be ignored)", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFromHookCreatesNewEntry(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
|
||||||
|
reg.UpdateFromHook("new-sess", "Working", "", "/home/user/project")
|
||||||
|
|
||||||
|
list := reg.List()
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registry len = %d, want 1", len(list))
|
||||||
|
}
|
||||||
|
if list[0].SessionID != "new-sess" {
|
||||||
|
t.Errorf("SessionID = %q, want %q", list[0].SessionID, "new-sess")
|
||||||
|
}
|
||||||
|
if list[0].State != "Working" {
|
||||||
|
t.Errorf("State = %q, want %q", list[0].State, "Working")
|
||||||
|
}
|
||||||
|
if list[0].Cwd != "/home/user/project" {
|
||||||
|
t.Errorf("Cwd = %q, want %q", list[0].Cwd, "/home/user/project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFromHookSetsWaitingSince(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
|
||||||
|
// Start as Working
|
||||||
|
reg.UpdateFromHook("sess-1", "Working", "", "/tmp")
|
||||||
|
|
||||||
|
list := reg.List()
|
||||||
|
if list[0].WaitingSince != nil {
|
||||||
|
t.Error("WaitingSince should be nil when Working")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition to Needs Input
|
||||||
|
reg.UpdateFromHook("sess-1", "Needs Input", "permission", "/tmp")
|
||||||
|
|
||||||
|
list = reg.List()
|
||||||
|
if list[0].WaitingSince == nil {
|
||||||
|
t.Fatal("WaitingSince should be set when transitioning to Needs Input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFromHookClearsWaitingSince(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
|
||||||
|
// Set up as Needs Input
|
||||||
|
reg.UpdateFromHook("sess-1", "Needs Input", "permission", "/tmp")
|
||||||
|
|
||||||
|
list := reg.List()
|
||||||
|
if list[0].WaitingSince == nil {
|
||||||
|
t.Fatal("WaitingSince should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition back to Working
|
||||||
|
reg.UpdateFromHook("sess-1", "Working", "", "/tmp")
|
||||||
|
|
||||||
|
list = reg.List()
|
||||||
|
if list[0].WaitingSince != nil {
|
||||||
|
t.Errorf("WaitingSince should be nil after returning to Working, got %v", list[0].WaitingSince)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type SessionInfo struct {
|
|||||||
Preview string `json:"preview"`
|
Preview string `json:"preview"`
|
||||||
Workspace string `json:"workspace"`
|
Workspace string `json:"workspace"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
WaitType string `json:"wait_type,omitempty"`
|
||||||
WaitingSince *time.Time `json:"waiting_since,omitempty"`
|
WaitingSince *time.Time `json:"waiting_since,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,49 @@ func TestResponseWithSessions(t *testing.T) {
|
|||||||
t.Error("waiting_since = nil, want non-nil")
|
t.Error("waiting_since = nil, want non-nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionInfoWaitTypeJSON(t *testing.T) {
|
||||||
|
// WaitType="permission" should appear in JSON
|
||||||
|
info := SessionInfo{
|
||||||
|
SessionID: "sess-1",
|
||||||
|
State: "Needs Input",
|
||||||
|
WaitType: "permission",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wt, ok := m["wait_type"]; !ok {
|
||||||
|
t.Error("wait_type field missing from JSON")
|
||||||
|
} else if wt != "permission" {
|
||||||
|
t.Errorf("wait_type = %v, want %q", wt, "permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitType="" should be omitted (omitempty)
|
||||||
|
info2 := SessionInfo{
|
||||||
|
SessionID: "sess-2",
|
||||||
|
State: "Working",
|
||||||
|
WaitType: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data2, err := json.Marshal(info2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m2 map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data2, &m2); err != nil {
|
||||||
|
t.Fatalf("unmarshal map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := m2["wait_type"]; ok {
|
||||||
|
t.Error("wait_type should be omitted when empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user