From e1b176cf55546f0720a71543079c20b12090fa78 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 19:40:02 +0100 Subject: [PATCH] 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) --- hook.go | 83 +++++++++++++++++ hook_test.go | 227 +++++++++++++++++++++++++++++++++++++++++++++++ protocol.go | 1 + protocol_test.go | 46 ++++++++++ 4 files changed, 357 insertions(+) create mode 100644 hook.go create mode 100644 hook_test.go diff --git a/hook.go b/hook.go new file mode 100644 index 0000000..411909d --- /dev/null +++ b/hook.go @@ -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 +} diff --git a/hook_test.go b/hook_test.go new file mode 100644 index 0000000..3d896d1 --- /dev/null +++ b/hook_test.go @@ -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) + } +} diff --git a/protocol.go b/protocol.go index 1595577..d2ceb18 100644 --- a/protocol.go +++ b/protocol.go @@ -28,6 +28,7 @@ type SessionInfo struct { Preview string `json:"preview"` Workspace string `json:"workspace"` Label string `json:"label,omitempty"` + WaitType string `json:"wait_type,omitempty"` WaitingSince *time.Time `json:"waiting_since,omitempty"` } diff --git a/protocol_test.go b/protocol_test.go index 21afa3b..d16087c 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -101,3 +101,49 @@ func TestResponseWithSessions(t *testing.T) { 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") + } +}