// Command keeld is the Keel daemon: it serves a local web UI and drives the // harness — the general collect→brain→act loop that hosts one mode at a time // (focus or offscreen). package main import ( "context" "fmt" "log" "os" "os/exec" "path/filepath" "runtime" "time" "keel/internal/ai" "keel/internal/ambient" "keel/internal/aw" "keel/internal/enforce" "keel/internal/evidence" "keel/internal/harness" "keel/internal/knowledge" "keel/internal/memory" "keel/internal/mode" "keel/internal/mode/focus" "keel/internal/mode/offscreen" "keel/internal/notify" "keel/internal/settings" "keel/internal/statusfile" "keel/internal/tasks" "keel/internal/web" ) const addr = "localhost:7777" func main() { home, err := os.UserHomeDir() if err != nil { log.Fatalf("resolve home dir: %v", err) } root := filepath.Join(home, ".keel") // Resolve and load settings, seeding from the legacy KEEL_* env vars on // first run. After first run the file is the sole source of truth; env is // ignored. A missing file is the first-run signal. settingsPath, err := settings.DefaultPath() if err != nil { log.Fatalf("resolve settings path: %v", err) } cfg, err := settings.Load(settingsPath) if err != nil { cfg = settings.SeedFromEnv() if err := settings.Save(settingsPath, cfg); err != nil { log.Printf("settings: could not write %s (continuing): %v", settingsPath, err) } else { log.Printf("settings: seeded %s from environment", settingsPath) } } // buildServices turns a Settings value into a fresh harness.Services. It // validates the AI backend FIRST and returns an error before constructing any // ports, so an invalid backend never half-rewires the harness. The same code // path serves startup and live POST /settings edits. buildServices := func(s settings.Settings) (harness.Services, error) { backend, err := ai.NewBackend(s.AIBackend) if err != nil { return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err) } svc := ai.NewService(backend) // Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on // first run, but Load returns whatever the JSON file holds on later starts. awURL := s.AWURL if awURL == "" { awURL = "http://localhost:5600" } var mem memory.Store = memory.NewNop() awc := aw.New(awURL) if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil { log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err) } else { mem = memory.NewAWStore(awc, "keel.events") log.Printf("memory: keel.events ready at %s", awURL) } return harness.Services{ AI: svc, Tasks: tasks.NewMarvin(s.MarvinCmd), Knowledge: knowledge.NewFileSource(s.KnowledgePath), Enforce: enforce.NewGuard(), Memory: mem, Clock: time.Now, }, nil } // Build the base services at startup. A bad persisted backend should not be // fatal: fall back to claude (always valid), persist the correction, and retry. base, err := buildServices(cfg) if err != nil { log.Printf("settings: %v; falling back to claude backend", err) cfg.AIBackend = "claude" if err := settings.Save(settingsPath, cfg); err != nil { log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err) } base, err = buildServices(cfg) if err != nil { log.Fatalf("settings: build failed even with claude: %v", err) } } log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath) log.Printf("enforce: window-minimize guard") // withDir namespaces a factory's persistence under ~/.keel/modes/; the // mode builds its own file paths under Services.Dir. withDir := func(dir string, f harness.Factory) harness.Factory { return func(s harness.Services) mode.Mode { s.Dir = dir return f(s) } } focusDir := filepath.Join(root, "modes", "focus") offscreenDir := filepath.Join(root, "modes", "offscreen") h := harness.New(base, map[string]harness.Factory{ "focus": withDir(focusDir, focus.Factory), "offscreen": withDir(offscreenDir, offscreen.Factory), }) // Re-adopt a live focus session restored from disk, if one was in flight. // Use h.Services() (Notify wired) rather than base (Notify nil) so the // restored mode's async completions reach the harness's onChange listeners. focusSvc := h.Services() focusSvc.Dir = focusDir if m, live, err := focus.Restore(focusSvc); err != nil { log.Printf("focus: restore failed (continuing idle): %v", err) } else if live { if err := h.Adopt(m); err != nil { log.Printf("focus: adopt restored session failed: %v", err) } else { log.Printf("focus: restored a live session") } } // 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 } // Compute the effective cadence so the log reflects what the sentinel will // actually use: New clamps a non-positive value (e.g. a settings.json that // predates these fields) to a default, mirrored here for the log line. ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second if ambCadence <= 0 { ambCadence = 5 * time.Minute } 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 }, }, ambCadence, ambMode) log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence) 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) // Mirror runtime status to ~/.keel_status for a window-manager bar. // A misconfigured home dir degrades to no status file, not a failed start. if statusPath, err := statusfile.DefaultPath(); err != nil { log.Printf("status file disabled: %v", err) } else { 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 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(), func(w evidence.WindowSnapshot) { h.RecordWindow(w) sentinel.OnWindow(w) }) go sentinel.Run(context.Background()) go openBrowser("http://" + addr) log.Printf("keeld listening on http://%s", addr) if err := srv.Router().Run(addr); err != nil { log.Fatalf("server: %v", err) } } // openBrowser best-effort launches the default browser after a short delay so // the server is listening first. Failures are logged, not fatal. func openBrowser(url string) { time.Sleep(300 * time.Millisecond) var cmd string var args []string switch runtime.GOOS { case "linux": cmd, args = "xdg-open", []string{url} case "darwin": cmd, args = "open", []string{url} case "windows": cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url} default: log.Printf("open browser manually: %s", url) return } if err := exec.Command(cmd, args...).Start(); err != nil { log.Printf("could not open browser (%v); visit %s", err, url) } }