From 097678e8391a4269c6cee3f460854ce3b457d3b1 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 20:49:48 -0400 Subject: [PATCH] Mirror runtime status to ~/.antidrift_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window-manager status bar can now read a single-line status file: a glyph plus minutes-remaining (● 24m / ⚠ DRIFT 24m / ● 24m ·? / ◔ planning / ✓ review), empty when idle. Rewritten on a one-minute tick, only when the line changes, and removed on shutdown. Degrades to no file if $HOME is unresolvable. Co-Authored-By: Claude Opus 4.8 --- cmd/antidriftd/main.go | 10 ++ internal/statusfile/statusfile.go | 118 ++++++++++++++++++++++++ internal/statusfile/statusfile_test.go | 123 +++++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 internal/statusfile/statusfile.go create mode 100644 internal/statusfile/statusfile_test.go diff --git a/cmd/antidriftd/main.go b/cmd/antidriftd/main.go index 6d9e2f7..340b462 100644 --- a/cmd/antidriftd/main.go +++ b/cmd/antidriftd/main.go @@ -13,6 +13,7 @@ import ( "antidrift/internal/ai" "antidrift/internal/evidence" "antidrift/internal/session" + "antidrift/internal/statusfile" "antidrift/internal/store" "antidrift/internal/web" ) @@ -45,6 +46,15 @@ func main() { log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name()) } + // Mirror runtime status to ~/.antidrift_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 { + go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background()) + log.Printf("status: writing %s", statusPath) + } + src := evidence.NewSource() go src.Watch(context.Background(), ctrl.RecordWindow) diff --git a/internal/statusfile/statusfile.go b/internal/statusfile/statusfile.go new file mode 100644 index 0000000..dc5034d --- /dev/null +++ b/internal/statusfile/statusfile.go @@ -0,0 +1,118 @@ +// Package statusfile mirrors the controller's runtime status into a single-line +// file (~/.antidrift_status) so an external consumer — a window-manager status +// bar — can display it with a plain file read. +package statusfile + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "antidrift/internal/domain" + "antidrift/internal/session" +) + +// statusFileName is the file written under the user's home directory. +const statusFileName = ".antidrift_status" + +// interval is how often the writer re-renders. Minute granularity is enough for +// a status bar, so a coarse tick keeps the write rate (and disk churn) low. +const interval = time.Minute + +// DefaultPath resolves ~/.antidrift_status. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, statusFileName), nil +} + +// Render produces the single status line for the given state. It is empty for +// Locked/idle so a bar module can hide itself. Drift status strings ("drifting") +// match the JSON contract the web UI already consumes. +func Render(st session.State, now time.Time) string { + switch st.RuntimeState { + case domain.RuntimeActive: + timer := remaining(st, now) + switch { + case st.Drift != nil && st.Drift.Status == "drifting": + return "⚠ DRIFT " + timer + case st.Drift != nil && st.Drift.Nudge != "": + return "● " + timer + " ·?" + default: + return "● " + timer + } + case domain.RuntimePlanning: + return "◔ planning" + case domain.RuntimeReview: + return "✓ review" + default: + return "" + } +} + +// remaining formats the minutes left until the commitment deadline, rounded up +// so a partial minute still reads as a minute and the count hits 0m only at the +// end. It returns 0m when no deadline is known. +func remaining(st session.State, now time.Time) string { + if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 { + return "0m" + } + left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now) + if left < 0 { + left = 0 + } + mins := int((left + time.Minute - 1) / time.Minute) // ceil + return fmt.Sprintf("%dm", mins) +} + +// Writer periodically renders the controller state and writes it to a file, +// rewriting only when the line changes. +type Writer struct { + path string + state func() session.State + now func() time.Time + + last string + wrote bool +} + +// NewWriter builds a Writer for path, reading state via the given accessor. +func NewWriter(path string, state func() session.State) *Writer { + return &Writer{path: path, state: state, now: time.Now} +} + +// Run writes the status file immediately, then on every tick when the rendered +// line has changed. It removes the file on ctx cancellation so a stale status +// does not linger after shutdown. +func (w *Writer) Run(ctx context.Context) { + t := time.NewTicker(interval) + defer t.Stop() + w.write() + for { + select { + case <-ctx.Done(): + _ = os.Remove(w.path) + return + case <-t.C: + w.write() + } + } +} + +func (w *Writer) write() { + line := Render(w.state(), w.now()) + if w.wrote && line == w.last { + return + } + if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil { + log.Printf("statusfile: write %s: %v", w.path, err) + return + } + w.last = line + w.wrote = true +} diff --git a/internal/statusfile/statusfile_test.go b/internal/statusfile/statusfile_test.go new file mode 100644 index 0000000..87bf079 --- /dev/null +++ b/internal/statusfile/statusfile_test.go @@ -0,0 +1,123 @@ +package statusfile + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "antidrift/internal/domain" + "antidrift/internal/session" +) + +func activeState(deadline int64, driftStatus, nudge string) session.State { + st := session.State{ + RuntimeState: domain.RuntimeActive, + Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline}, + } + if driftStatus != "" || nudge != "" { + st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge} + } + return st +} + +func TestRender(t *testing.T) { + now := time.Unix(1_000_000, 0) + in24m := now.Add(24 * time.Minute).Unix() + + cases := []struct { + name string + st session.State + want string + }{ + {"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""}, + {"empty runtime", session.State{}, ""}, + {"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"}, + {"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"}, + {"active ontask", activeState(in24m, "ontask", ""), "● 24m"}, + {"active no drift view", activeState(in24m, "", ""), "● 24m"}, + {"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"}, + {"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"}, + {"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"}, + {"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"}, + {"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"}, + {"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Render(tc.st, now); got != tc.want { + t.Errorf("Render() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestWriterWritesAndDedups(t *testing.T) { + path := filepath.Join(t.TempDir(), "status") + now := time.Unix(1_000_000, 0) + state := session.State{RuntimeState: domain.RuntimePlanning} + w := NewWriter(path, func() session.State { return state }) + w.now = func() time.Time { return now } + + w.write() + if got := readFile(t, path); got != "◔ planning" { + t.Fatalf("first write = %q", got) + } + + // Same state: file untouched. Detect by clearing it and confirming the + // deduped write does not recreate content. + if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil { + t.Fatal(err) + } + w.write() + if got := readFile(t, path); got != "sentinel" { + t.Errorf("deduped write should not rewrite, got %q", got) + } + + // Changed state: rewrites. + state = session.State{RuntimeState: domain.RuntimeLocked} + w.write() + if got := readFile(t, path); got != "" { + t.Errorf("changed write = %q, want empty", got) + } +} + +func TestWriterRemovesFileOnCancel(t *testing.T) { + path := filepath.Join(t.TempDir(), "status") + w := NewWriter(path, func() session.State { + return session.State{RuntimeState: domain.RuntimePlanning} + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { w.Run(ctx); close(done) }() + + // Wait for the immediate write. + waitFor(t, func() bool { _, err := os.Stat(path); return err == nil }) + cancel() + <-done + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file should be removed on cancel, stat err = %v", err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met within timeout") +}