feat(daemon): add background usage monitor with HTTP/SSE API

Implement a long-running daemon service that continuously polls Claude
Code session logs and exposes usage data via local HTTP endpoints.

Architecture:
- internal/daemon/service.go: Core Service struct managing poll loop,
  snapshot computation, event buffering, and HTTP handlers
- cmd/daemon.go: Cobra commands for start/status/stop with detach mode

HTTP Endpoints (default 127.0.0.1:8787):
- GET /healthz         - Liveness probe for orchestration
- GET /v1/status       - Current aggregate snapshot + daemon runtime info
- GET /v1/events       - Recent event buffer as JSON array
- GET /v1/stream       - Server-Sent Events for real-time updates

Snapshot model captures:
- Session/prompt/API call counts
- Token totals and estimated cost
- Cache hit rate
- Rolling daily averages (cost/day, tokens/day, sessions/day)

Delta detection emits events only when usage actually changes, keeping
the event stream lean for downstream consumers.

Detach mode (-d, --detach):
- Forks a child process with stdout/stderr redirected to log file
- Writes PID file for process management
- Parent exits after confirming child is running

This daemon serves as the foundation for planned capabilities like
incident replay, runaway detection, and session classification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-28 00:04:55 -05:00
parent 7157886546
commit 8c1beb7a8a
3 changed files with 808 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
package daemon
import (
"math"
"testing"
"time"
)
func TestDiffSnapshots(t *testing.T) {
prev := Snapshot{
Sessions: 10,
Prompts: 100,
APICalls: 120,
Tokens: 1_000_000,
EstimatedCostUSD: 10.5,
}
curr := Snapshot{
Sessions: 12,
Prompts: 112,
APICalls: 136,
Tokens: 1_250_000,
EstimatedCostUSD: 13.1,
}
delta := diffSnapshots(prev, curr)
if delta.Sessions != 2 {
t.Fatalf("Sessions delta = %d, want 2", delta.Sessions)
}
if delta.Prompts != 12 {
t.Fatalf("Prompts delta = %d, want 12", delta.Prompts)
}
if delta.APICalls != 16 {
t.Fatalf("APICalls delta = %d, want 16", delta.APICalls)
}
if delta.Tokens != 250_000 {
t.Fatalf("Tokens delta = %d, want 250000", delta.Tokens)
}
if math.Abs(delta.EstimatedCostUSD-2.6) > 1e-9 {
t.Fatalf("Cost delta = %.2f, want 2.60", delta.EstimatedCostUSD)
}
if delta.isZero() {
t.Fatal("delta unexpectedly reported as zero")
}
}
func TestPublishEventRingBuffer(t *testing.T) {
s := New(Config{
DataDir: ".",
Interval: 10 * time.Second,
EventsBuffer: 2,
})
s.publishEvent(Event{ID: 1})
s.publishEvent(Event{ID: 2})
s.publishEvent(Event{ID: 3})
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.events) != 2 {
t.Fatalf("events len = %d, want 2", len(s.events))
}
if s.events[0].ID != 2 || s.events[1].ID != 3 {
t.Fatalf("events ring contains IDs [%d, %d], want [2, 3]", s.events[0].ID, s.events[1].ID)
}
}