diff --git a/cmd/antidriftd/main.go b/cmd/antidriftd/main.go index ebbd144..81ea200 100644 --- a/cmd/antidriftd/main.go +++ b/cmd/antidriftd/main.go @@ -97,7 +97,11 @@ func main() { if statusPath, err := statusfile.DefaultPath(); err != nil { log.Printf("status file disabled: %v", err) } else { - go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background()) + writer := statusfile.NewWriter(statusPath, ctrl.State) + // Re-render the bar on every state change, not just the coarse tick, so a + // drift transition reaches the status bar as fast as it reaches the web UI. + ctrl.AddOnChange(writer.Wake) + go writer.Run(context.Background()) log.Printf("status: writing %s", statusPath) } diff --git a/internal/evidence/context.go b/internal/evidence/context.go index c9877d2..96263ca 100644 --- a/internal/evidence/context.go +++ b/internal/evidence/context.go @@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool { return false } for _, allowed := range ctx.WindowClasses { - if normalizeCasefolded(allowed) == candidate { + // Substring, not equality: a short token a user types ("brave") matches the + // longer real WM_CLASS ("Brave-browser"). Equality silently disabled the + // fast path whenever the two differed, routing every window to the judge. + if a := normalizeCasefolded(allowed); a != "" && strings.Contains(candidate, a) { return true } } diff --git a/internal/evidence/context_test.go b/internal/evidence/context_test.go index de307b9..48c39ff 100644 --- a/internal/evidence/context_test.go +++ b/internal/evidence/context_test.go @@ -19,6 +19,20 @@ func TestWindowClassMatchesCaseAndTrim(t *testing.T) { } } +func TestWindowClassMatchesSubstring(t *testing.T) { + // Users type a short app name ("brave") but the real WM_CLASS is longer + // ("Brave-browser"). The allowed token matching as a substring of the actual + // class keeps the local on-task fast path working instead of silently routing + // every window to the LLM judge. + ctx := domain.AllowedContext{WindowClasses: []string{"brave"}} + if !windowClassAllowed(ctx, "Brave-browser") { + t.Fatal("short allowed token should match the longer real class") + } + if windowClassAllowed(ctx, "firefox") { + t.Fatal("unrelated class must not match") + } +} + func TestWindowTitleMatchesSubstring(t *testing.T) { ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}} if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") { diff --git a/internal/session/drift.go b/internal/session/drift.go index b8931aa..854a899 100644 --- a/internal/session/drift.go +++ b/internal/session/drift.go @@ -76,15 +76,15 @@ func (c *Controller) resetDriftLocked() { c.driftGen++ c.nudgeEpoch++ c.lastJudgedAt = time.Time{} - c.judgedClasses = map[string]ai.Verdict{} + 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 any cached verdict for that class, and persists. The class now -// matches locally and is never re-judged. +// drift, drops the cached verdict for the current window, and persists. The +// class now matches locally and is never re-judged. func (c *Controller) OnTask() error { c.mu.Lock() defer c.mu.Unlock() @@ -100,7 +100,7 @@ func (c *Controller) OnTask() error { if !evidence.MatchesAllowed(ac, class, "") { c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class)) } - delete(c.judgedClasses, class) + delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title)) } c.driftStatus = driftOnTask c.driftReason = "" @@ -108,7 +108,7 @@ func (c *Controller) OnTask() error { } // Refocus clears the current drift verdict without changing allowed-context, and -// drops the cached verdict for the current class so it may be judged again later. +// drops the cached verdict for the current window so it may be judged again later. func (c *Controller) Refocus() error { c.mu.Lock() defer c.mu.Unlock() @@ -116,7 +116,7 @@ func (c *Controller) Refocus() error { return ErrNotActive } if c.stats != nil { - delete(c.judgedClasses, c.stats.Current.Class) + delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title)) } c.driftStatus = driftIdle c.driftReason = "" @@ -193,8 +193,12 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap c.nudgeMessage = "" c.nudgeEpoch++ - // 2. Per-class cache. - if v, ok := c.judgedClasses[class]; ok { + // 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 } @@ -241,9 +245,9 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap c.notify() return } - c.judgedClasses[class] = v + c.judgedWindows[key] = v var enforceAct func() - if c.stats != nil && c.stats.Current.Class == class { + if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key { c.applyVerdictLocked(v) enforceAct = c.enforceActionLocked() } @@ -314,6 +318,15 @@ func (c *Controller) recordTitleLocked(title string) { } } +// 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 *Controller) applyVerdictLocked(v ai.Verdict) { if v.OnTask { diff --git a/internal/session/session.go b/internal/session/session.go index 4a29625..924a8eb 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -43,7 +43,7 @@ type Controller struct { auditPath string sessionsDir string clock func() time.Time - onChange func() + onChanges []func() latestWindow evidence.WindowSnapshot stats *EvidenceStats outcomePending string @@ -80,7 +80,7 @@ type Controller struct { driftGen int nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard lastJudgedAt time.Time - judgedClasses map[string]ai.Verdict + judgedWindows map[string]ai.Verdict nudge ai.Nudger recentTitles []string // in-memory ring of recent distinct titles this session @@ -119,7 +119,7 @@ func New(snapshotPath string) (*Controller, error) { c.allowedClasses = s.AllowedWindowClasses c.enforcementLevel = s.EnforcementLevel // Drift state is not persisted: recompute fresh after restart to avoid - // stale interrupts. This also initializes judgedClasses (the pipeline + // stale interrupts. This also initializes judgedWindows (the pipeline // writes to it) so a restored Active session never panics on a nil map. c.resetDriftLocked() c.replayStats(s.SessionID) @@ -135,20 +135,32 @@ func (c *Controller) SetClock(f func() time.Time) { c.mu.Unlock() } -// SetOnChange registers a callback fired after an evidence-driven state change -// (focus updates). It is invoked with the mutex released. +// SetOnChange registers the sole change listener, replacing any already set. A +// listener is fired after a state change (focus updates, role results) with the +// mutex released. Use AddOnChange to register additional listeners. func (c *Controller) SetOnChange(f func()) { c.mu.Lock() - c.onChange = f + c.onChanges = []func(){f} + c.mu.Unlock() +} + +// AddOnChange registers an additional change listener alongside any already set, +// so several consumers (the web broadcaster, the status-file writer) all react +// to the same notification rather than only the last one wired. +func (c *Controller) AddOnChange(f func()) { + c.mu.Lock() + c.onChanges = append(c.onChanges, f) c.mu.Unlock() } func (c *Controller) notify() { c.mu.Lock() - f := c.onChange + fs := append([]func(){}, c.onChanges...) c.mu.Unlock() - if f != nil { - f() + for _, f := range fs { + if f != nil { + f() + } } } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index e0a2783..baa4e61 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -386,6 +386,29 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) { } } +func TestPlanningStateExposesCurrentWindowClass(t *testing.T) { + c, _ := newTestController(t) + c.RecordWindow(obs("Brave-browser", "Consume - Brave")) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("planning: %v", err) + } + st := c.State() + if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" { + t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence) + } +} + +func TestAddOnChangeFansOutToAllListeners(t *testing.T) { + c, _ := newTestController(t) + var a, b int + c.SetOnChange(func() { a++ }) + c.AddOnChange(func() { b++ }) + c.notify() + if a != 1 || b != 1 { + t.Fatalf("every registered listener should fire: a=%d b=%d", a, b) + } +} + func TestOnTaskAppendsCurrentClass(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() @@ -478,7 +501,54 @@ func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) { } } -func TestPerClassCacheAvoidsSecondJudge(t *testing.T) { +// titleJudge returns a verdict chosen by which substring the title contains, +// so a test can model an app (e.g. a browser) hosting both on- and off-task +// windows under one window class. +type titleJudge struct { + byTitle map[string]ai.Verdict + calls int32 +} + +func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) { + atomic.AddInt32(&j.calls, 1) + for sub, v := range j.byTitle { + if strings.Contains(title, sub) { + return v, nil + } + } + return ai.Verdict{OnTask: true}, nil +} + +// A brief off-task window must not poison every later same-class window. Teams +// and a Consume reading page are both class "Brave-browser"; caching the judge +// verdict by class alone made the on-task page inherit Teams's drift verdict +// (and never re-judge), latching drift for the rest of the session. +func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) { + c, _ := newTestController(t) + now := time.Now() + c.SetClock(func() time.Time { return now }) + fj := &titleJudge{byTitle: map[string]ai.Verdict{ + "Teams": {OnTask: false, Reason: "Teams is off-task"}, + "Consume": {OnTask: true}, + }} + c.SetDriftJudge(fj) + startActive(t, c, []string{"code"}) // Brave-browser never matches locally + + c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave")) + waitDriftStatus(t, c, "drifting") + + now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed + c.RecordWindow(obs("Brave-browser", "Consume - Brave")) + st := waitDriftStatus(t, c, "ontask") + if st.Drift.Reason != "" { + t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift) + } + if got := atomic.LoadInt32(&fj.calls); got != 2 { + t.Fatalf("different title under same class should re-judge, calls=%d", got) + } +} + +func TestPerWindowCacheAvoidsSecondJudge(t *testing.T) { c, _ := newTestController(t) now := time.Now() c.SetClock(func() time.Time { return now }) @@ -488,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) { c.RecordWindow(obs("firefox", "YouTube")) waitDriftStatus(t, c, "drifting") // Advance past the debounce window so a second judge call would be allowed: - // this isolates the per-class cache as the only reason the judge is skipped. + // this isolates the per-window cache as the only reason the judge is skipped. now = now.Add(2 * driftDebounce) - c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache + c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match) + c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache time.Sleep(50 * time.Millisecond) if got := atomic.LoadInt32(&fj.calls); got != 1 { - t.Fatalf("cached class should not re-judge, calls=%d", got) + t.Fatalf("cached window should not re-judge, calls=%d", got) } } @@ -554,7 +625,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) { t.Fatalf("reload: %v", err) } second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map + second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedWindows map waitDriftStatus(t, second, "drifting") } @@ -714,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) { c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) startActive(t, c, []string{"code"}) c.RecordWindow(obs("firefox", "YouTube")) - c.RecordWindow(obs("firefox", "Reddit")) waitDriftStatus(t, c, "drifting") + c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history time.Sleep(50 * time.Millisecond) if got := atomic.LoadInt32(&fn.calls); got != 0 { t.Fatalf("nudge must not run on the drift path, calls=%d", got) @@ -1484,7 +1555,8 @@ func TestRecordWindowCreditsSplitFaithfully(t *testing.T) { waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment clk.advance(8 * time.Minute) - c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting + c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task + waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited clk.advance(4 * time.Minute) if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task t.Fatalf("complete: %v", err) diff --git a/internal/session/views.go b/internal/session/views.go index 3fe9e6d..c28e7b1 100644 --- a/internal/session/views.go +++ b/internal/session/views.go @@ -125,6 +125,14 @@ func (c *Controller) stateLocked() State { } } if c.runtimeState == domain.RuntimePlanning { + // Surface the live window so planning can show the real class to copy into + // the allowed-apps field — exact class names are otherwise invisible, and a + // non-matching token silently disables the local on-task fast path. + st.Evidence = &EvidenceView{ + Available: c.latestWindow.Health.Available, + Reason: c.latestWindow.Health.Reason, + Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title}, + } status := c.coachStatus if status == "" { status = coachIdle diff --git a/internal/statusfile/statusfile.go b/internal/statusfile/statusfile.go index dc5034d..ea51122 100644 --- a/internal/statusfile/statusfile.go +++ b/internal/statusfile/statusfile.go @@ -76,6 +76,7 @@ type Writer struct { path string state func() session.State now func() time.Time + wake chan struct{} last string wrote bool @@ -83,12 +84,26 @@ type Writer struct { // 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} + return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)} } -// 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. +// Wake asks the writer to re-render now rather than at the next tick. It is the +// hook the controller'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() @@ -98,6 +113,8 @@ func (w *Writer) Run(ctx context.Context) { case <-ctx.Done(): _ = os.Remove(w.path) return + case <-w.wake: + w.write() case <-t.C: w.write() } diff --git a/internal/statusfile/statusfile_test.go b/internal/statusfile/statusfile_test.go index 87bf079..38fb434 100644 --- a/internal/statusfile/statusfile_test.go +++ b/internal/statusfile/statusfile_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "sync" "testing" "time" @@ -41,7 +42,7 @@ func TestRender(t *testing.T) { {"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"}, {"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"}, {"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"}, - {"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"}, + {"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"}, {"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"}, } for _, tc := range cases { @@ -101,6 +102,38 @@ func TestWriterRemovesFileOnCancel(t *testing.T) { } } +// Wake makes the writer re-render on a state change instead of waiting for the +// coarse minute tick, so the status bar tracks drift transitions promptly +// instead of lagging the web UI by up to a minute. +func TestWriterWakeRendersPromptly(t *testing.T) { + path := filepath.Join(t.TempDir(), "status") + now := time.Unix(1_000_000, 0) + in10m := now.Add(10 * time.Minute).Unix() + + var mu sync.Mutex + st := activeState(in10m, "ontask", "") + w := NewWriter(path, func() session.State { mu.Lock(); defer mu.Unlock(); return st }) + w.now = func() time.Time { return now } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go w.Run(ctx) + + waitFor(t, func() bool { return fileEquals(path, "● 10m") }) + + mu.Lock() + st = activeState(in10m, "drifting", "") + mu.Unlock() + w.Wake() + + waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") }) +} + +func fileEquals(path, want string) bool { + b, err := os.ReadFile(path) + return err == nil && string(b) == want +} + func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 9bf3bcf..5344cdf 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -73,6 +73,12 @@ input { border-radius: 8px; color: var(--ink); font: inherit; } input:focus { outline: 0; border-color: var(--accent); } +/* Checkboxes must opt out of the full-width text-input styling above, which + otherwise stretches the box across the row and shoves its label adrift. */ +input[type="checkbox"] { + width: auto; flex: none; margin: 0; padding: 0; accent-color: var(--accent); +} +.enforce-toggle { justify-content: flex-start; } .btn { margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px; diff --git a/internal/web/static/app.js b/internal/web/static/app.js index d862a5b..2c298d6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -120,6 +120,24 @@ function updateActiveDrift(state) { on task · ${switches} switches`; } +// updatePlanningWindowHint shows the live foreground window class so the user +// can copy the exact token into the allowed-apps field — class names are +// otherwise invisible, and a non-matching token silently disables on-task +// matching. Clicking the chip appends the class if not already listed. +function updatePlanningWindowHint(state) { + const el = document.getElementById('appHint'); + if (!el) return; + const cls = state.evidence && state.evidence.current && state.evidence.current.class; + if (!cls) { el.innerHTML = ''; return; } + el.innerHTML = `current window: `; + document.getElementById('addClass').onclick = () => { + const apps = document.getElementById('apps'); + if (!apps) return; + const have = apps.value.split(',').map(s => s.trim()).filter(Boolean); + if (!have.includes(cls)) { have.push(cls); apps.value = have.join(', '); } + }; +} + function updatePlanningCoach(coach) { const statusEl = document.getElementById('coachStatus'); if (!statusEl) return; @@ -213,6 +231,7 @@ function render(state) { updatePlanningTasks(state.tasks); updatePlanningKnowledge(state.knowledge); updatePlanningReflection(state.reflection); + updatePlanningWindowHint(state); return; } if (rs === 'active' && renderedState === 'active') { @@ -248,6 +267,7 @@ function render(state) { +

Minimize off-task windows when you drift.

@@ -272,6 +292,7 @@ function render(state) { updatePlanningTasks(state.tasks); updatePlanningKnowledge(state.knowledge); updatePlanningReflection(state.reflection); + updatePlanningWindowHint(state); } else if (rs === 'active') { const c = state.commitment || {};