- Notifier + FocusTimer fields in Daemon, initialized in NewDaemon - processHookEvent notifies on Working -> Needs Input only (D-01) - Focus mode suppresses notifications when active (D-05) - FocusArgs in protocol, "focus" handler in daemon - CLI "vmux focus <minutes>" command - Tests for all notification transitions and focus handler
128 lines
3.4 KiB
Go
128 lines
3.4 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
|
|
}
|
|
|
|
// Read PrevState BEFORE UpdateFromHook overwrites it
|
|
d.registry.mu.RLock()
|
|
prevState := ""
|
|
if ts, ok := d.registry.sessions[event.SessionID]; ok {
|
|
prevState = ts.PrevState
|
|
}
|
|
d.registry.mu.RUnlock()
|
|
|
|
d.registry.UpdateFromHook(event.SessionID, state, waitType, event.Cwd)
|
|
|
|
// Notify only on Working -> Needs Input transition (D-01)
|
|
if state == "Needs Input" && prevState == "Working" && !d.focus.IsActive() {
|
|
d.registry.mu.RLock()
|
|
info := d.registry.sessions[event.SessionID].Info
|
|
d.registry.mu.RUnlock()
|
|
d.notifier.Notify("vmux: "+shortName(info), "Session needs input ("+waitType+")")
|
|
}
|
|
|
|
d.mu.Lock()
|
|
d.lastHookTime = time.Now()
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// 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
|
|
}
|