Generalize the focus controller into a harness hosting swappable modes
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>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package focus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"keel/internal/ai"
|
||||
"keel/internal/enforce"
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/mode/focus/domain"
|
||||
"keel/internal/store"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
enforceTimeout = 5 * time.Second
|
||||
nudgeDebounce = 5 * time.Minute
|
||||
nudgeTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const recentTitlesMax = 10
|
||||
|
||||
const (
|
||||
driftIdle = "idle"
|
||||
driftPending = "pending"
|
||||
driftOnTask = "ontask"
|
||||
driftDrifting = "drifting"
|
||||
)
|
||||
|
||||
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||
// working; unmatched windows simply stay idle.
|
||||
func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
|
||||
c.mu.Lock()
|
||||
c.judge = j
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
|
||||
// enforcement; everything else behaves identically.
|
||||
func (c *Mode) SetGuard(g enforce.Guard) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.guard = g
|
||||
}
|
||||
|
||||
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
|
||||
// local matching and the drift judge are unaffected.
|
||||
func (c *Mode) SetNudge(n ai.Nudger) {
|
||||
c.mu.Lock()
|
||||
c.nudge = n
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// commitmentLineLocked renders the active commitment as a single line for AI
|
||||
// prompts, or "" if there is none. Caller holds mu.
|
||||
func (c *Mode) commitmentLineLocked() string {
|
||||
if c.commitment == nil {
|
||||
return ""
|
||||
}
|
||||
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
|
||||
}
|
||||
|
||||
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
|
||||
func (c *Mode) recentTitlesForTest() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.recentTitles...)
|
||||
}
|
||||
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Mode) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.nudgeEpoch++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedWindows = map[string]ai.Verdict{}
|
||||
c.recentTitles = nil
|
||||
c.nudgeMessage = ""
|
||||
c.lastNudgedAt = time.Time{}
|
||||
}
|
||||
|
||||
// OnTask appends the current window class to the session allowed-context, clears
|
||||
// drift, drops the cached verdict for the current window, and persists. The
|
||||
// class now matches locally and is never re-judged.
|
||||
func (c *Mode) OnTask() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.runtimeState != domain.RuntimeActive {
|
||||
return ErrNotActive
|
||||
}
|
||||
var class string
|
||||
if c.stats != nil {
|
||||
class = c.stats.Current.Class
|
||||
}
|
||||
if strings.TrimSpace(class) != "" {
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if !evidence.MatchesAllowed(ac, class, "") {
|
||||
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
||||
}
|
||||
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
|
||||
}
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return c.persistLocked()
|
||||
}
|
||||
|
||||
// Refocus clears the current drift verdict without changing allowed-context, and
|
||||
// drops the cached verdict for the current window so it may be judged again later.
|
||||
func (c *Mode) Refocus() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.runtimeState != domain.RuntimeActive {
|
||||
return ErrNotActive
|
||||
}
|
||||
if c.stats != nil {
|
||||
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
|
||||
}
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
return c.persistLocked()
|
||||
}
|
||||
|
||||
// RecordWindow ingests one sensor observation. It accumulates time only while
|
||||
// Active, always tracks the latest window for display, and fires onChange after
|
||||
// releasing the mutex.
|
||||
func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
|
||||
c.mu.Lock()
|
||||
c.latestWindow = snap
|
||||
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
return
|
||||
}
|
||||
now := c.clock()
|
||||
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||
c.applyEvent(now, snap)
|
||||
c.recordTitleLocked(snap.Title)
|
||||
launch := c.evaluateDriftLocked(now, snap)
|
||||
enforceAct := c.enforceActionLocked()
|
||||
c.mu.Unlock()
|
||||
if launch != nil {
|
||||
go launch()
|
||||
}
|
||||
if enforceAct != nil {
|
||||
go enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
}
|
||||
|
||||
// enforceActionLocked returns the minimize thunk when this observation should be
|
||||
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
|
||||
// returned func performs blocking X11 I/O and MUST run after the caller releases
|
||||
// c.mu, following the off-lock async-I/O discipline used by the other roles.
|
||||
func (c *Mode) enforceActionLocked() func() {
|
||||
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
|
||||
return nil
|
||||
}
|
||||
guard := c.guard
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
|
||||
defer cancel()
|
||||
if err := guard.MinimizeActive(ctx); err != nil {
|
||||
log.Printf("focus: enforce minimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateDriftLocked runs the local-first drift pipeline for one observation
|
||||
// and updates synchronous drift state. When an async judgment is warranted it
|
||||
// returns the judging closure; the caller runs it in a goroutine after
|
||||
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
|
||||
// already verified the runtime is Active.
|
||||
func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
|
||||
class, title := snap.Class, snap.Title
|
||||
|
||||
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
|
||||
// where the semantic nudge runs — the drift judge stays silent here, so the
|
||||
// two are mutually exclusive per observation.
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if evidence.MatchesAllowed(ac, class, title) {
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return c.maybeNudgeLocked(now)
|
||||
}
|
||||
|
||||
// Past this point the window is not a local on-task match: the on-task
|
||||
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
|
||||
// epoch so an in-flight nudge result is discarded rather than surfacing stale
|
||||
// when the user returns.
|
||||
c.nudgeMessage = ""
|
||||
c.nudgeEpoch++
|
||||
|
||||
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
|
||||
// app must not leak to a sibling window of the same class — a browser hosts
|
||||
// both an on-task reading page and an off-task chat under one window class, so
|
||||
// keying by class alone latched one tab's verdict onto every other tab.
|
||||
key := verdictKey(class, title)
|
||||
if v, ok := c.judgedWindows[key]; ok {
|
||||
c.applyVerdictLocked(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. No judge wired: never block; leave idle.
|
||||
if c.judge == nil {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Debounce: at most one judgment per driftDebounce window.
|
||||
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
|
||||
return nil
|
||||
}
|
||||
|
||||
prevStatus, prevReason := c.driftStatus, c.driftReason
|
||||
c.driftGen++
|
||||
gen := c.driftGen
|
||||
c.lastJudgedAt = now
|
||||
c.driftStatus = driftPending
|
||||
c.driftReason = ""
|
||||
judge := c.judge
|
||||
commitment := c.commitmentLineLocked()
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
|
||||
defer cancel()
|
||||
v, err := judge.JudgeDrift(ctx, commitment, class, title)
|
||||
|
||||
c.mu.Lock()
|
||||
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
|
||||
c.mu.Unlock()
|
||||
return // stale: a newer judgment started or the session ended
|
||||
}
|
||||
if err != nil {
|
||||
// Degrade: never fabricate drift. Revert the optimistic pending.
|
||||
if c.driftStatus == driftPending {
|
||||
c.driftStatus = prevStatus
|
||||
c.driftReason = prevReason
|
||||
}
|
||||
c.mu.Unlock()
|
||||
log.Printf("focus: drift judge failed: %v", err)
|
||||
c.notify()
|
||||
return
|
||||
}
|
||||
c.judgedWindows[key] = v
|
||||
var enforceAct func()
|
||||
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
|
||||
c.applyVerdictLocked(v)
|
||||
enforceAct = c.enforceActionLocked()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if enforceAct != nil {
|
||||
enforceAct()
|
||||
}
|
||||
c.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
|
||||
// local-match path. It returns the nudging closure when eligible (judge wired,
|
||||
// at least two titles of history, debounce elapsed), else nil. The closure
|
||||
// follows the drift-judge discipline: it runs in a goroutine after the caller
|
||||
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
|
||||
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
|
||||
// episode or a session change — is discarded rather than surfacing stale, never
|
||||
// fabricates a concern on error, and notifies only after unlocking. Caller holds
|
||||
// mu and has already verified the runtime is Active.
|
||||
func (c *Mode) maybeNudgeLocked(now time.Time) func() {
|
||||
if c.nudge == nil || len(c.recentTitles) < 2 {
|
||||
return nil
|
||||
}
|
||||
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
|
||||
return nil
|
||||
}
|
||||
c.lastNudgedAt = now
|
||||
epoch := c.nudgeEpoch
|
||||
nudge := c.nudge
|
||||
commitment := c.commitmentLineLocked()
|
||||
titles := append([]string(nil), c.recentTitles...)
|
||||
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
|
||||
defer cancel()
|
||||
msg, err := nudge.Nudge(ctx, commitment, titles)
|
||||
|
||||
c.mu.Lock()
|
||||
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
|
||||
c.mu.Unlock()
|
||||
return // stale: the on-task stretch ended (drift episode) or the session changed
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
log.Printf("focus: nudge failed: %v", err)
|
||||
return // never fabricate a concern; leave any prior message intact
|
||||
}
|
||||
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
|
||||
c.mu.Unlock()
|
||||
c.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
|
||||
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
|
||||
func (c *Mode) recordTitleLocked(title string) {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
|
||||
return
|
||||
}
|
||||
c.recentTitles = append(c.recentTitles, t)
|
||||
if len(c.recentTitles) > recentTitlesMax {
|
||||
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// verdictKey identifies the judged window for the per-window verdict cache. It
|
||||
// combines the window class with the scrubbed, casefolded title so sibling
|
||||
// windows of one app (a browser's reading tab vs its chat tab) are judged
|
||||
// independently, while transient title noise (clocks, counts) does not split a
|
||||
// window into ever-changing keys that defeat the cache.
|
||||
func verdictKey(class, title string) string {
|
||||
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
|
||||
}
|
||||
|
||||
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
|
||||
func (c *Mode) applyVerdictLocked(v ai.Verdict) {
|
||||
if v.OnTask {
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return
|
||||
}
|
||||
c.driftStatus = driftDrifting
|
||||
c.driftReason = v.Reason
|
||||
}
|
||||
Reference in New Issue
Block a user