// 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/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/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") } } srv := web.NewServer(h) srv.Init() // re-arm or expire a restored deadline-bearing session // 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 { next, err := buildServices(s) if err != nil { return err } h.SetServices(next) 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) // 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. h.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. src := evidence.NewSource() go src.Watch(context.Background(), h.RecordWindow) 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) } }