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) <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-03-23 19:45:48 +01:00
parent 5f13eb174b
commit 79ad8fb16a
2 changed files with 64 additions and 5 deletions

View File

@@ -3,7 +3,9 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net" "net"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -155,6 +157,10 @@ type Daemon struct {
pollInterval time.Duration pollInterval time.Duration
stopCh chan struct{} stopCh chan struct{}
listener net.Listener listener net.Listener
hookPort int
httpServer *http.Server
lastHookTime time.Time
mu sync.Mutex // protects lastHookTime
} }
// NewDaemon creates a daemon ready to start. // NewDaemon creates a daemon ready to start.
@@ -167,6 +173,7 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
claudeDir: claudeDir, claudeDir: claudeDir,
pollInterval: 5 * time.Second, pollInterval: 5 * time.Second,
stopCh: make(chan struct{}), 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. // and polls for sessions in the background.
func (d *Daemon) Start() error { func (d *Daemon) Start() error {
// Synchronous initial scan before accepting connections // Synchronous initial scan before accepting connections
d.scanOnce(time.Now()) 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 { if err := d.cleanStaleSocket(); err != nil {
return fmt.Errorf("clean stale socket: %w", err) return fmt.Errorf("clean stale socket: %w", err)
} }
@@ -218,6 +273,9 @@ func (d *Daemon) Stop() {
default: default:
close(d.stopCh) close(d.stopCh)
} }
if d.httpServer != nil {
d.httpServer.Close()
}
if d.listener != nil { if d.listener != nil {
d.listener.Close() d.listener.Close()
} }
@@ -244,14 +302,11 @@ func (d *Daemon) acceptLoop() {
} }
func (d *Daemon) pollLoop() { func (d *Daemon) pollLoop() {
ticker := time.NewTicker(d.pollInterval)
defer ticker.Stop()
for { for {
select { select {
case <-d.stopCh: case <-d.stopCh:
return return
case t := <-ticker.C: case t := <-time.After(d.currentPollInterval()):
d.scanOnce(t) d.scanOnce(t)
} }
} }

View File

@@ -71,6 +71,10 @@ func (d *Daemon) processHookEvent(event HookEvent) {
} }
d.registry.UpdateFromHook(event.SessionID, state, waitType, event.Cwd) 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. // UpdateFromHook updates a session from a hook event.