// 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 }