diff --git a/.planning/phases/03-hook-server/03-RESEARCH.md b/.planning/phases/03-hook-server/03-RESEARCH.md new file mode 100644 index 0000000..82309e1 --- /dev/null +++ b/.planning/phases/03-hook-server/03-RESEARCH.md @@ -0,0 +1,494 @@ +# 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 (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 + + + +## 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". | + + +## 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 : + +```json +{ + "session_id": "abc123", + "transcript_path": "/home/pierre/.claude/projects/.../uuid.jsonl", + "cwd": "/home/pierre/Code/vibe/vmux", + "hook_event_name": "Notification" +} +``` + +**Notification** ajoute : +```json +{ + "notification_type": "permission_prompt", + "message": "Claude needs your permission to use Bash", + "title": "Permission needed" +} +``` + +**Stop** ajoute : +```json +{ + "stop_hook_active": true, + "last_assistant_message": "I've completed..." +} +``` + +**PostToolUse** ajoute : +```json +{ + "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` : + +```go +type SessionInfo struct { + // ... champs existants ... + WaitType string `json:"wait_type,omitempty"` // "permission", "question", "idle", "" +} +``` + +### Configuration hooks dans settings.json + +Ajouter dans `~/.claude/settings.json` : + +```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) + +```go +// 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 + +```go +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 + +```go +// 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 + +```go +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 + +```go +// 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 + +```go +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 + +```go +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](https://code.claude.com/docs/en/hooks) - 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](https://algoinsights.medium.com/claude-code-just-got-http-hooks-heres-why-that-changes-everything-6938ffaae1f6) - 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)