From 79ad8fb16a97b1b7175a85f462b1fb6294b60c66 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 19:45:48 +0100 Subject: [PATCH] feat(03-02): integrate hook server into daemon with graceful degradation and dynamic poll - Add hookPort, httpServer, lastHookTime, mu to Daemon struct - startHookServer binds HTTP on hookPort, degrades if port busy - Stop() closes httpServer before unix listener - pollLoop uses time.After(currentPollInterval()) instead of fixed ticker - currentPollInterval returns 20s when hooks active (<60s), 5s otherwise - processHookEvent records lastHookTime on each hook event Co-Authored-By: Claude Opus 4.6 (1M context) --- daemon.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- hook.go | 4 ++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/daemon.go b/daemon.go index 7dd0d6b..c799b25 100644 --- a/daemon.go +++ b/daemon.go @@ -3,7 +3,9 @@ package main import ( "encoding/json" "fmt" + "log" "net" + "net/http" "os" "path/filepath" "sync" @@ -155,6 +157,10 @@ type Daemon struct { pollInterval time.Duration stopCh chan struct{} listener net.Listener + hookPort int + httpServer *http.Server + lastHookTime time.Time + mu sync.Mutex // protects lastHookTime } // NewDaemon creates a daemon ready to start. @@ -167,6 +173,7 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon claudeDir: claudeDir, pollInterval: 5 * time.Second, stopCh: make(chan struct{}), + hookPort: 3119, } } @@ -185,12 +192,60 @@ func (d *Daemon) InitWorkspaceResolver(treeProvider I3TreeProvider, x11 X11PIDRe } } -// Start runs the daemon: initial scan, then listens on the Unix socket +// startHookServer starts an HTTP server on hookPort to receive Claude Code hooks. +// If the port is busy, logs a warning and returns nil (graceful degradation). +func (d *Daemon) startHookServer() error { + if d.hookPort == 0 { + return nil + } + + addr := fmt.Sprintf("127.0.0.1:%d", d.hookPort) + + mux := http.NewServeMux() + mux.HandleFunc("/hook", d.handleHook) + + d.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Printf("hook server: port %d busy, continuing without hooks: %v", d.hookPort, err) + d.httpServer = nil + return nil + } + + go d.httpServer.Serve(ln) + return nil +} + +// currentPollInterval returns 20s when hooks are active (last hook < 60s ago), +// 5s otherwise. +func (d *Daemon) currentPollInterval() time.Duration { + d.mu.Lock() + lastHook := d.lastHookTime + d.mu.Unlock() + + if !lastHook.IsZero() && time.Since(lastHook) < 60*time.Second { + return 20 * time.Second + } + return 5 * time.Second +} + +// Start runs the daemon: initial scan, hook server, then listens on the Unix socket // and polls for sessions in the background. func (d *Daemon) Start() error { // Synchronous initial scan before accepting connections d.scanOnce(time.Now()) + // Start hook server (graceful degradation if port busy) + if err := d.startHookServer(); err != nil { + return fmt.Errorf("start hook server: %w", err) + } + if err := d.cleanStaleSocket(); err != nil { return fmt.Errorf("clean stale socket: %w", err) } @@ -218,6 +273,9 @@ func (d *Daemon) Stop() { default: close(d.stopCh) } + if d.httpServer != nil { + d.httpServer.Close() + } if d.listener != nil { d.listener.Close() } @@ -244,14 +302,11 @@ func (d *Daemon) acceptLoop() { } func (d *Daemon) pollLoop() { - ticker := time.NewTicker(d.pollInterval) - defer ticker.Stop() - for { select { case <-d.stopCh: return - case t := <-ticker.C: + case t := <-time.After(d.currentPollInterval()): d.scanOnce(t) } } diff --git a/hook.go b/hook.go index 93de376..eaf97a7 100644 --- a/hook.go +++ b/hook.go @@ -71,6 +71,10 @@ func (d *Daemon) processHookEvent(event HookEvent) { } d.registry.UpdateFromHook(event.SessionID, state, waitType, event.Cwd) + + d.mu.Lock() + d.lastHookTime = time.Now() + d.mu.Unlock() } // UpdateFromHook updates a session from a hook event.