Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.2 KiB
Settings file + settings page — design
Date: 2026-06-01 Status: Approved (brainstorming), pending implementation plan
Problem
The three configurable inputs — AI backend, Marvin tasks command, and knowledge
profile path — are read once from ANTIDRIFT_* environment variables at daemon
startup (cmd/antidriftd/main.go). Changing any of them means editing a launch
command and restarting. The user wants to edit them at runtime from the web UI,
backed by a persisted settings file, and to pick the knowledge profile with a
proper file picker rather than typing an absolute path.
A partial precedent already exists: POST /knowledge/path → SetKnowledgePath
re-points the profile live while planning, but the choice is session-only and
not persisted.
Decisions (locked during brainstorming)
- Live apply — saving a setting re-wires the running daemon immediately; no restart. (Knowledge path already works this way.)
- Server-side browse endpoint for the file picker — a browser
<input type=file>yields sandboxed file contents, not a server-side path the daemon can re-read, so we expose a directory-listing endpoint and render our own navigable picker. - Env seeds first run, then file wins — on first start with no settings file, seed it from current env vars (or built-in defaults); thereafter the settings file is the sole source of truth and env vars are ignored.
- Scope: just the three settings — AI backend, Marvin command, knowledge path. No port / default timebox / default enforcement in this version.
- Gear toggle on the main card — a header gear icon toggles a settings overlay in the existing single-page, SSE-driven UI; no new route.
Architecture
Key layering move: inject an "applier" closure
web must NOT import ai / tasks / knowledge. Re-wiring adapters is a
composition concern, not session state, so it does not belong on the controller
either. Instead main.go (the composition root) builds:
applyFn := func(s settings.Settings) error { /* construct adapters, call ctrl setters */ }
and injects it into the server, exactly as SetCoach / SetTasks already inject
behavior. The HTTP handler shrinks to: validate → save file → call applier →
broadcast. web depends only on the settings.Settings type and the injected
closure.
Dependency direction: web → settings (type only) and web → applyFn (injected).
main → {settings, ai, tasks, knowledge, session, web}. No cycles.
New package: internal/settings
A leaf type package, importing nothing else in the app.
type Settings struct {
AIBackend string `json:"ai_backend"`
MarvinCmd string `json:"marvin_cmd"`
KnowledgePath string `json:"knowledge_path"`
}
func DefaultPath() (string, error) // ~/.antidrift/settings.json
func Load(path string) (Settings, error)
func Save(path string, s Settings) error // atomic temp+rename, mirrors store
func SeedFromEnv() Settings // reads ANTIDRIFT_AI_BACKEND/_MARVIN_CMD/_KNOWLEDGE_FILE
Settings file (~/.antidrift/settings.json, next to state.json):
{
"ai_backend": "claude",
"marvin_cmd": "uv run --project /home/felixm/dev/ampy am --json --config /home/felixm/dev/ampy/config.json",
"knowledge_path": "/home/felixm/.antidrift/knowledge.md"
}
Startup wiring (main.go)
Replace the three inline os.Getenv(...) wirings with:
- Resolve
settings.DefaultPath(). - If the file is absent:
s := settings.SeedFromEnv()(filling built-in defaults where env is unset), thensettings.Save(path, s). If present:s, _ := settings.Load(path)and env is ignored. applyFn(s)performs the wiring main does today:b, err := ai.NewBackend(s.AIBackend); on error log + skip AI (as today); elsesvc := ai.NewService(b)and callSetCoach/SetDriftJudge/SetNudge/SetReviewer.ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd)).- Knowledge unified: base source
ctrl.SetKnowledge(knowledge.NewFileSource(""))once, thenctrl.SetKnowledgePath(s.KnowledgePath)to drive the actual path. Startup and live-edit then take the identical code path.
- Hand
applyFn, the loadeds, and the settings file path to the server.
HTTP endpoints (web)
Added in a new internal/web/settings_handlers.go to keep web.go focused. The
server gains mutex-guarded fields: current settings.Settings, the settings file
path, and the injected applyFn.
GET /settings→ current settings as JSON.POST /settings→ body is the full settings object. The handler callsapplyFn(s)to validate-and-apply atomically:applyFnchecks the backend first and returnssettings.ErrInvalidBackendBEFORE mutating any controller state on a bad value. On that error → 400, nothing saved, nothing applied, prior wiring intact. On success the handler then persists:settings.Save→ update in-memory copy → broadcast → 200 with the saved settings. Marvin command and knowledge path are free-form and always accepted (bad values degrade gracefully exactly as today).GET /fs/browse?dir=<abspath>→{ "dir": "...", "parent": "...", "entries": [ {"name","path","is_dir"} ] }. Lists subdirectories plus.mdfiles. Ifdiris empty/missing, open at the directory of the current knowledge path, else$HOME. Rejects a nonexistent/unreadable dir with 400. No filesystem jail beyond OS permissions — localhost-only, single-user daemon, same trust boundary as the rest of the UI.
Validation placement (decided): the backend-name check lives in the injected
applyFn, which returns a typed settings.ErrInvalidBackend (or similar) BEFORE
mutating any controller state. The handler maps that error to 400 and skips the
save. This keeps web free of an ai import; applyFn (built in main) owns
all adapter knowledge including what counts as a valid backend.
Redundant endpoint removed
POST /knowledge/path becomes redundant (knowledge path now flows through
/settings). Fold it in and delete the route + handleKnowledgePath. The
existing app.js "change profile" affordance is replaced by the settings
overlay's knowledge field.
UI (app.js / app.css)
- A gear button in the card header toggles a settings overlay over the current card. No routing; reachable from any runtime state.
- On open,
GET /settingspopulates three controls:- AI backend:
<select>with claude / codex. - Marvin command: text field.
- Knowledge path: text field + Browse… button.
- AI backend:
- Browse… opens a lightweight modal driven by
GET /fs/browse: click a folder to descend,..to go up, click a.mdfile to select it back into the path field. - Save posts the full object to
/settings. A 400 renders the error inline and keeps the overlay open. On success the overlay closes; the SSE stream delivers refreshed state.
Data flow (save)
gear → overlay → edit fields → Save
→ POST /settings {ai_backend, marvin_cmd, knowledge_path}
→ handler: applyFn(s) validates backend first, mutates only on full success
invalid → 400 (no apply, no save)
valid → applyFn applied (SetCoach/…, SetTasks, SetKnowledgePath)
→ settings.Save(file) → server.current = s → broadcast → 200
→ overlay closes, SSE pushes new state
Error handling
- Invalid AI backend: 400, nothing persisted or applied; prior wiring intact.
- Unreadable/missing settings file at startup: treat as first run (seed + save); if the save itself fails, log and continue with the in-memory seed (daemon still starts, matching the existing degrade-not-fail posture).
- Bad Marvin command / missing knowledge file: accepted and saved; degrade at fetch/load time exactly as today (no tasks panel / ungrounded coach).
/fs/browseon a bad dir: 400 with a short reason; the modal shows it and stays on the last good directory.
Testing
internal/settings: Load/Save round-trip;DefaultPath;SeedFromEnvreads the three env vars; first-run path (file absent → seed written).internal/web:GET /fs/browselists entries, filters to dirs +.md, exposes correctparent, and returns 400 for a nonexistent dir.POST /settingswith an invalid backend → 400, applier NOT called, file unchanged (assert via a fake applier returningErrInvalidBackend).POST /settingswith valid input → applier called once with the parsed settings and the file written (fake applier records its argument).
- The real
applyFnstays thin inmain(composition root); its constituent constructors (ai.NewBackend,tasks.NewMarvin,knowledge.NewFileSource,SetKnowledgePath) are already covered by their own packages.
Files touched
internal/settings/settings.go(new) +settings_test.gocmd/antidriftd/main.go(wiring + applyFn)internal/web/settings_handlers.go(new) + testsinternal/web/web.go(register routes, server fields, drop/knowledge/path)internal/web/static/app.js,internal/web/static/app.css(gear, overlay, browse modal)
Out of scope (YAGNI)
- Port, default timebox, default enforcement level as settings.
- Multiple knowledge profiles / profile management.
- Any auth on the browse endpoint beyond the existing localhost binding.