Files
vmux/hook.go
Pierre Martin 5bec9430b7 feat(03-01): HTTP handler POST /hook with validation and body size limit
- handleHook validates POST method (405 on others)
- MaxBytesReader limits body to 64KB (400 on overflow)
- JSON decode errors return 400
- Valid payloads update registry via processHookEvent
- 4 tests cover OK, method not allowed, bad JSON, body too large

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:41:11 +01:00

108 lines
2.8 KiB
Go

package main
import (
"encoding/json"
"net/http"
"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"`
}
// handleHook is the HTTP handler for POST /hook.
// Validates method, limits body size, decodes JSON, and dispatches to processHookEvent.
func (d *Daemon) handleHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var event HookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
d.processHookEvent(event)
w.WriteHeader(http.StatusOK)
}
// 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
}