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 (
"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)
}
}

View File

@@ -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.