19 KiB
Phase 3: Hook Server - Research
Researched: 2026-03-23 Domain: Claude Code hooks HTTP, Go net/http server, state detection push Confidence: HIGH
Summary
Claude Code supporte nativement les hooks HTTP (type "http") dans settings.json. vmuxd doit exposer un serveur HTTP local qui recoit les events POST de Claude Code. Chaque event contient session_id, cwd, transcript_path, et hook_event_name. Les events pertinents pour vmux sont : Notification (avec notification_type : permission_prompt, idle_prompt), Stop (session terminee), PostToolUse (session active), et PreToolUse (session active).
Le format est simple : Claude Code POST du JSON sur l'URL configuree, attend une reponse 2xx. Le serveur vmuxd parse le payload, met a jour le registre, et repond 200 OK. Pas besoin de lib externe, net/http stdlib suffit.
Primary recommendation: Ajouter un http.Server dans le Daemon qui ecoute sur localhost:3119, configurer les hooks HTTP dans ~/.claude/settings.json, et mapper les events vers des mises a jour imm du registre avec le nouveau champ WaitType.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Hooks comme source primaire. Le poll reste actif mais ralenti (15-30s) comme filet de securite pour les sessions sans hooks configures ou en cas d'event rate.
- D-02: Quand un hook event arrive, le registre est mis a jour immediatement (pas besoin d'attendre le prochain cycle de poll).
Claude's Discretion
- Port HTTP pour le hook server (ex: localhost:3119 ou port dynamique)
- Format exact des requetes hook Claude Code (consulter la doc officielle)
- Mapping events hook vers types d'attente (permission_prompt, idle_prompt, etc.)
- Comment les hooks Claude Code sont configures (fichier .claude/settings.json ou equivalent)
- Intervalle du poll fallback ralenti (entre 15s et 30s)
Deferred Ideas (OUT OF SCOPE)
None </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| STATE-03 | vmux distingue le type d'attente (permission prompt, question utilisateur, idle prompt) | Les hooks Notification fournissent notification_type avec valeurs permission_prompt et idle_prompt. Le hook Stop + last_assistant_message permet de detecter end_turn (question utilisateur). PostToolUse confirme "Working". |
| </phase_requirements> |
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| net/http (stdlib) | Go 1.25 | HTTP server | Suffisant pour un serveur local simple. Pas besoin de framework. |
| encoding/json (stdlib) | Go 1.25 | JSON parsing | Deja utilise partout dans le projet. |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| net/http/httptest (stdlib) | Go 1.25 | Test HTTP handlers | Pour les tests unitaires du hook server sans ouvrir de port. |
Alternatives Considered
| Recommended | Alternative | When to Use Alternative |
|---|---|---|
| net/http stdlib | chi / gin / echo | Jamais pour ce cas. Un seul endpoint POST, zero besoin de routing avance. |
| Port fixe 3119 | Port dynamique (port 0) | Si conflit de port. Mais le port dynamique compliquerait la configuration hooks dans settings.json (il faudrait un script qui decouvre le port). |
Architecture Patterns
Hook Server Integration dans le Daemon
Le hook server est une goroutine supplementaire dans le Daemon existant, comme pollLoop et acceptLoop.
Daemon
|-- acceptLoop() (Unix socket, deja existant)
|-- pollLoop() (scan /proc, deja existant, sera ralenti)
|-- hookServerLoop() (NEW: HTTP server localhost:3119)
Payload Claude Code (verifie avec la doc officielle)
Tous les events recoivent ces champs communs :
{
"session_id": "abc123",
"transcript_path": "/home/pierre/.claude/projects/.../uuid.jsonl",
"cwd": "/home/pierre/Code/vibe/vmux",
"hook_event_name": "Notification"
}
Notification ajoute :
{
"notification_type": "permission_prompt",
"message": "Claude needs your permission to use Bash",
"title": "Permission needed"
}
Stop ajoute :
{
"stop_hook_active": true,
"last_assistant_message": "I've completed..."
}
PostToolUse ajoute :
{
"tool_name": "Bash",
"tool_input": { "command": "..." }
}
Mapping Events vers WaitType
| Hook Event | notification_type | WaitType dans vmux | SessionState |
|---|---|---|---|
| Notification | permission_prompt |
"permission" |
NeedsInput |
| Notification | idle_prompt |
"idle" |
NeedsInput |
| Stop | - | "question" |
NeedsInput |
| PostToolUse | - | "" (clear) |
Working |
| PreToolUse | - | "" (clear) |
Working |
Le champ WaitType est ajoute a SessionInfo :
type SessionInfo struct {
// ... champs existants ...
WaitType string `json:"wait_type,omitempty"` // "permission", "question", "idle", ""
}
Configuration hooks dans settings.json
Ajouter dans ~/.claude/settings.json :
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{
"type": "http",
"url": "http://localhost:3119/hook",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3119/hook",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3119/hook",
"timeout": 5
}
]
}
]
}
}
Un seul endpoint /hook suffit. Le hook_event_name dans le payload permet de router.
Cohabitation Poll / Hooks (D-01)
// Quand un hook event arrive, reset le timer de poll pour eviter le double scan
type Daemon struct {
// ... existant ...
hookPort int
httpServer *http.Server
pollInterval time.Duration // passe de 5s a 20s quand hooks actifs
lastHookTime time.Time // pour savoir si les hooks fonctionnent
}
Strategie : si lastHookTime < 60s, le poll est en mode fallback (20s). Sinon, revenir au poll rapide (5s) pour couvrir les sessions sans hooks.
Pattern du Handler HTTP
func (d *Daemon) handleHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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)
}
Anti-Patterns to Avoid
- Over-engineering le routing: Un seul endpoint
/hookavec dispatch surhook_event_name. Pas de router, pas de middleware. - Bloquer sur le hook: Le handler doit repondre vite (< 100ms). La mise a jour du registre est un lock rapide, pas de I/O.
- Ignorer le timeout: Claude Code attend max 30s (defaut HTTP hooks). Repondre en < 100ms elimine ce risque.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| HTTP server | Framework (chi, gin) | net/http stdlib | Un seul endpoint, zero routing complexe |
| JSON parsing du payload | Struct generique + reflection | Structs Go typees par event | Plus simple, plus rapide, testable |
| Settings.json generation | Script qui modifie settings.json | Documentation manuelle + vmux setup futur |
Modifier settings.json d'un utilisateur est dangereux |
Common Pitfalls
Pitfall 1: Port deja occupe
What goes wrong: Le daemon ne demarre pas si le port 3119 est deja pris.
Why it happens: Autre instance de vmuxd, ou autre service local.
How to avoid: Essayer de bind, si erreur logger un warning et continuer sans hooks (fallback poll uniquement). Ne pas crasher le daemon.
Warning signs: listen tcp 127.0.0.1:3119: bind: address already in use
Pitfall 2: Session ID mismatch entre hooks et poll
What goes wrong: Le hook envoie un session_id que le registre ne connait pas encore (session pas encore scannee par le poll).
Why it happens: Le hook arrive avant le premier scan du poll pour cette session.
How to avoid: Le hook handler doit creer une entree dans le registre meme si la session n'existe pas encore. Le poll la completera au prochain cycle.
Warning signs: Hook events ignores silencieusement.
Pitfall 3: Hooks pas configures
What goes wrong: Le serveur HTTP tourne mais ne recoit aucun event.
Why it happens: L'utilisateur n'a pas ajoute les hooks dans ~/.claude/settings.json.
How to avoid: Commande vmux setup ou documentation claire. Logger un warning si aucun hook recu apres 60s de fonctionnement avec des sessions actives.
Warning signs: vmux list montre des etats avec le delai du poll (5-20s) au lieu du temps reel.
Pitfall 4: Hook settings.json ecrase les hooks existants
What goes wrong: L'utilisateur a deja des hooks PostToolUse (comme rtk-rewrite.sh, piaire-post.sh). Ajouter vmux ne doit pas les supprimer.
Why it happens: Chaque event dans settings.json est un tableau. vmux ajoute un element, pas un remplacement.
How to avoid: La config vmux s'ajoute au tableau existant. Le matcher "*" ou absent capte tous les events.
Warning signs: Les hooks existants (RTK, piaire) cessent de fonctionner.
Pitfall 5: Body trop grand / request malformee
What goes wrong: Le handler panic ou bloque sur un body de taille illimitee.
Why it happens: Pas de limit reader sur le body.
How to avoid: http.MaxBytesReader(w, r.Body, 64*1024) (64KB largement suffisant).
Warning signs: Memory spike sur le daemon.
Code Examples
HookEvent struct
// HookEvent represents the JSON payload sent by Claude Code hooks.
type HookEvent struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Cwd string `json:"cwd"`
HookEventName string `json:"hook_event_name"`
// Notification-specific
NotificationType string `json:"notification_type,omitempty"`
Message string `json:"message,omitempty"`
Title string `json:"title,omitempty"`
// Stop-specific
LastAssistantMsg string `json:"last_assistant_message,omitempty"`
StopHookActive bool `json:"stop_hook_active,omitempty"`
// PostToolUse-specific
ToolName string `json:"tool_name,omitempty"`
}
processHookEvent
func (d *Daemon) processHookEvent(event HookEvent) {
if event.SessionID == "" {
return
}
var state string
var 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)
d.lastHookTime = time.Now()
}
Registry.UpdateFromHook
// 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
}
HTTP Server startup dans Daemon
func (d *Daemon) startHookServer() error {
mux := http.NewServeMux()
mux.HandleFunc("/hook", d.handleHook)
d.httpServer = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", d.hookPort),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", d.httpServer.Addr)
if err != nil {
log.Printf("Warning: hook server unavailable on port %d: %v (poll-only mode)", d.hookPort, err)
return nil // graceful degradation
}
go d.httpServer.Serve(ln)
log.Printf("Hook server listening on %s", d.httpServer.Addr)
return nil
}
Test pattern avec httptest
func TestHandleHookNotification(t *testing.T) {
d := newTestDaemon(t)
d.hookPort = 0 // not used in direct handler test
body := `{
"session_id": "sess-1",
"hook_event_name": "Notification",
"notification_type": "permission_prompt",
"cwd": "/home/user/project"
}`
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body))
w := httptest.NewRecorder()
d.handleHook(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
list := d.registry.List()
if len(list) != 1 {
t.Fatalf("registry len = %d, want 1", len(list))
}
if list[0].WaitType != "permission" {
t.Errorf("WaitType = %q, want %q", list[0].WaitType, "permission")
}
if list[0].State != "Needs Input" {
t.Errorf("State = %q, want %q", list[0].State, "Needs Input")
}
}
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Go testing (stdlib) |
| Config file | Makefile (target test) |
| Quick run command | nix-shell --run "go test -v -run TestHook ./..." |
| Full suite command | nix-shell --run "go test -v -race ./..." |
Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| STATE-03a | Hook event Notification/permission_prompt met a jour WaitType="permission" | unit | nix-shell --run "go test -v -run TestHandleHookNotification ./..." |
Wave 0 |
| STATE-03b | Hook event Notification/idle_prompt met a jour WaitType="idle" | unit | nix-shell --run "go test -v -run TestHandleHookIdle ./..." |
Wave 0 |
| STATE-03c | Hook event Stop met a jour WaitType="question" | unit | nix-shell --run "go test -v -run TestHandleHookStop ./..." |
Wave 0 |
| STATE-03d | Hook event PostToolUse met a jour State="Working" et clear WaitType | unit | nix-shell --run "go test -v -run TestHandleHookPostToolUse ./..." |
Wave 0 |
| STATE-03e | WaitType visible dans vmux list (SessionInfo JSON) |
unit | nix-shell --run "go test -v -run TestSessionInfoWaitType ./..." |
Wave 0 |
| STATE-03f | Poll ralenti a 20s quand hooks actifs | unit | nix-shell --run "go test -v -run TestPollSlowdown ./..." |
Wave 0 |
| STATE-03g | Graceful degradation si port occupe | unit | nix-shell --run "go test -v -run TestHookServerPortBusy ./..." |
Wave 0 |
Sampling Rate
- Per task commit:
nix-shell --run "go test -v -run TestHook ./..." - Per wave merge:
nix-shell --run "go test -v -race ./..." - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
hook_test.go-- tests du hook HTTP handler (STATE-03a/b/c/d)protocol_test.go-- test du champ WaitType dans SessionInfo (STATE-03e)daemon_test.go-- test poll slowdown et graceful degradation (STATE-03f/g)
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Hooks command-only | Hooks HTTP natif | Claude Code 2025-Q4 | Plus besoin de scripts shell intermediaires |
| 12 hook events | 22 hook events (mars 2026) | Mars 2026 | SessionEnd, PreCompact, PostCompact, ConfigChange, etc. |
| Pas de notification_type | Matcher par notification_type | 2026 | Permet de filtrer permission_prompt vs idle_prompt |
Open Questions
-
Faut-il un
vmux setupqui modifie settings.json automatiquement ?- Ce qu'on sait : settings.json est un fichier partage avec les hooks existants (RTK, piaire, GSD). Le modifier programmatiquement risque d'ecraser des configurations.
- Recommandation : Phase 3 fournit la documentation de la configuration. Un
vmux setupautomatique peut etre ajoute plus tard (pas dans le scope STATE-03).
-
Le
idle_promptse declenche quand exactement ?- Ce qu'on sait : quand Claude Code affiche le prompt apres avoir fini de repondre (l'utilisateur n'a pas encore tape de message). C'est equivalent au
end_turn+ idle. - Ce qui n'est pas clair : la difference exacte entre
idle_promptetStop. En pratique,Stopfire quand Claude finit de repondre,idle_promptfire quand le prompt attend depuis un moment. - Recommandation : traiter les deux.
Stop="question",idle_prompt="idle".
- Ce qu'on sait : quand Claude Code affiche le prompt apres avoir fini de repondre (l'utilisateur n'a pas encore tape de message). C'est equivalent au
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Go | Build | Oui (nix-shell) | 1.25.7 | -- |
| net/http (stdlib) | Hook server | Oui | Go 1.25 | -- |
| Claude Code hooks HTTP | Push events | Oui (settings.json) | 22 events (mars 2026) | Poll-only (5s) |
| Port 3119 TCP | Hook server | A verifier au runtime | -- | Log warning, poll-only |
Sources
Primary (HIGH confidence)
- Claude Code Hooks Reference - Documentation officielle complete des 22 events, format payload HTTP, configuration settings.json
~/.claude/settings.jsonsur la machine locale - Verification directe du format existant des hooks command- Code existant vmux (daemon.go, state.go, protocol.go) - Architecture actuelle du daemon
Secondary (MEDIUM confidence)
- Algo Insights - Claude Code HTTP Hooks - Confirmation des HTTP hooks
- Hooks existants (rtk-rewrite.sh, piaire-post.sh, gsd-context-monitor.js) - Format stdin/stdout des hooks command verifie, confirme les champs JSON
Metadata
Confidence breakdown:
- Standard stack: HIGH - stdlib Go, pas de dependance externe
- Architecture: HIGH - Pattern hook HTTP verifie dans la doc officielle, integration daemon existante claire
- Pitfalls: HIGH - Verifies par inspection du code existant et des hooks deja configures
Research date: 2026-03-23 Valid until: 2026-04-23 (stable, pas de breaking changes attendus)