diff --git a/cmd/keeld/main.go b/cmd/keeld/main.go index 62e0be5..fc209b0 100644 --- a/cmd/keeld/main.go +++ b/cmd/keeld/main.go @@ -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)