feat(keeld): wire the ambient drift coach sentinel

Automated smoke test results:
- GET /: 200
- POST /ambient/snooze: 204
- GET /settings: includes ambient_mode and ambient_cadence_secs fields
- daemon log: "ambient: mode=notify cadence=0s" (cadence=0 because existing
  settings.json predates these fields; ambient.New defaults to 5 min when
  cadence is non-positive — correct degradation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:57:14 -04:00
parent aaed1ad265
commit 6c81a0d4f8
+43 -7
View File
@@ -14,6 +14,7 @@ import (
"time"
"keel/internal/ai"
"keel/internal/ambient"
"keel/internal/aw"
"keel/internal/enforce"
"keel/internal/evidence"
@@ -23,6 +24,7 @@ import (
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/notify"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
@@ -139,20 +141,51 @@ func main() {
}
}
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
// mode is active. Backend/port changes need a daemon restart to reach it; live
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
// services").
ambMode := cfg.AmbientMode
if ambMode == "" {
ambMode = settings.AmbientNotify
}
sentinel := ambient.New(ambient.Deps{
AI: base.AI,
Knowledge: base.Knowledge,
Tasks: base.Tasks,
Notifier: notify.NewNotifier(),
Memory: base.Memory,
Clock: time.Now,
ActiveMode: func() string { return h.State().ActiveMode },
}, time.Duration(cfg.AmbientCadenceSecs)*time.Second, ambMode)
log.Printf("ambient: mode=%s cadence=%ds", ambMode, cfg.AmbientCadenceSecs)
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
sentinel.AddOnChange(srv.Broadcast)
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
}
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
mode := s.AmbientMode
if mode == "" {
mode = settings.AmbientNotify
}
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
@@ -162,19 +195,22 @@ func main() {
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State)
// Re-render the bar on every harness change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
h.AddOnChange(writer.Wake)
sentinel.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into the harness, which forwards it to the
// active mode iff that mode consumes evidence. On a headless box the source is
// a no-op, so this degrades gracefully without new X11 requirements.
// Feed the window sensor stream into BOTH the harness (for the active mode)
// and the ambient sentinel (always-on). On a headless box the source is a
// no-op, so both degrade gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), h.RecordWindow)
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
h.RecordWindow(w)
sentinel.OnWindow(w)
})
go sentinel.Run(context.Background())
go openBrowser("http://" + addr)