Files
vmux/.planning/phases/03-hook-server/03-RESEARCH.md
2026-03-23 19:29:45 +01:00

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 /hook avec dispatch sur hook_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

  1. Faut-il un vmux setup qui 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 setup automatique peut etre ajoute plus tard (pas dans le scope STATE-03).
  2. Le idle_prompt se 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_prompt et Stop. En pratique, Stop fire quand Claude finit de repondre, idle_prompt fire quand le prompt attend depuis un moment.
    • Recommandation : traiter les deux. Stop = "question", idle_prompt = "idle".

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.json sur 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)