Mirror runtime status to ~/.antidrift_status
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user