7d69a1f320
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
5.4 KiB
Go
188 lines
5.4 KiB
Go
// 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
|
|
}
|