// Package statusfile mirrors the harness's runtime status into a single-line // file (~/.keel_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" "strings" "time" "keel/internal/mode" "keel/internal/mode/focus" "keel/internal/mode/focus/domain" ) // statusFileName is the file written under the user's home directory. const statusFileName = ".keel_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 ~/.keel_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 harness envelope, dispatching // on the active mode. An idle harness ("") renders "idle"; an unknown kind // renders a generic fallback so a future mode degrades gracefully rather than // rendering nothing. func Render(env mode.Envelope, now time.Time) string { switch env.ActiveMode { case "": return "idle" case "focus": st, ok := env.Mode.(focus.State) if !ok { // Defensive: the envelope claims focus but carries an unexpected // payload. Render a safe fallback instead of panicking on the assert. return "focus" } return renderFocus(st, now) case "offscreen": return renderOffscreen(env.Mode) default: return env.ActiveMode } } // renderOffscreen produces the off-screen status line from the mode's untyped // View payload (a map[string]any read from the live in-process envelope). It // shows the proposed next action when one is present, a warning glyph on error, // and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the // placeholder rather than panicking. func renderOffscreen(m any) string { const thinking = "off-screen: thinking…" view, ok := m.(map[string]any) if !ok { return thinking } status, _ := view["status"].(string) switch status { case "proposed": na, _ := view["next_action"].(string) if na = strings.TrimSpace(na); na != "" { return "off-screen: " + na } return thinking case "error": return "off-screen: ⚠" default: return thinking } } // renderFocus produces the focus status line. It is empty for Locked/idle focus // states so a bar module can hide itself. Drift status strings ("drifting") // match the JSON contract the web UI already consumes. func renderFocus(st focus.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 focus.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 harness envelope and writes it to a file, // rewriting only when the line changes. type Writer struct { path string state func() mode.Envelope now func() time.Time wake chan struct{} last string wrote bool } // NewWriter builds a Writer for path, reading the harness envelope via the given // accessor (typically harness.State). func NewWriter(path string, state func() mode.Envelope) *Writer { return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)} } // Wake asks the writer to re-render now rather than at the next tick. It is the // hook the harness's change notifications fire through, so drift transitions // reach the status bar promptly instead of lagging the web UI by up to a tick. // The signal is coalesced (buffered, size 1): a burst of changes collapses into // a single re-render, and the actual write still happens on the Run goroutine, // so concurrent callers never race on the file. func (w *Writer) Wake() { select { case w.wake <- struct{}{}: default: // a re-render is already pending } } // Run writes the status file immediately, then re-renders on each wake or tick // when the rendered line has changed. The tick still advances the minute // countdown when nothing else changes. 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 <-w.wake: w.write() 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 }