13633ffabf
Three independent defects made the focus state feel flaky and let the OS
status bar disagree with the web UI:
- Status file lagged the web by up to a minute: it rendered only on a 60s
ticker with no hook into state changes. notify() now fans out to multiple
listeners (AddOnChange) and the writer has a coalesced Wake() so drift
reaches the bar as promptly as the browser.
- A brief off-task visit could latch drift for the whole session. Sibling
windows of one app (a browser's reading tab vs its chat tab) share a window
class, but the judge verdict was cached by class alone, so one tab's verdict
poisoned the rest and never re-judged. Cache is now keyed by class + scrubbed
title (judgedWindows) so siblings are judged independently.
- Allowed-class matching was exact equality, so a short token ("brave") never
matched the real WM_CLASS ("Brave-browser") and every window was routed to
the LLM. Matching is now substring-based, and planning surfaces the live
window class with a click-to-add chip so users pick a token that matches.
Also fix the planning checkbox alignment: the global full-width input rule was
stretching the "Enforce focus" checkbox; scope it out for checkboxes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
6.5 KiB
Go
198 lines
6.5 KiB
Go
package session
|
|
|
|
import (
|
|
"antidrift/internal/domain"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// CommitmentView is the UI projection of the active commitment.
|
|
type CommitmentView struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxSecs int64 `json:"timebox_secs"`
|
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
|
|
}
|
|
|
|
// ProposalView / CoachView project the ephemeral planning-coach state.
|
|
type ProposalView struct {
|
|
NextAction string `json:"next_action"`
|
|
SuccessCondition string `json:"success_condition"`
|
|
TimeboxSecs int64 `json:"timebox_secs"`
|
|
|
|
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
|
}
|
|
|
|
// DriftView is the active-only drift projection. Nudge is a separate axis from
|
|
// Status: it is populated precisely when Status is "ontask" (semantic drift
|
|
// inside an allowed app), so it cannot be folded into the status enum.
|
|
type DriftView struct {
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Nudge string `json:"nudge,omitempty"`
|
|
Enforced bool `json:"enforced,omitempty"`
|
|
}
|
|
|
|
type CoachView struct {
|
|
Status string `json:"status"`
|
|
Proposal *ProposalView `json:"proposal,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// TaskView is one to-do item in the planning tasks list.
|
|
type TaskView struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Day string `json:"day,omitempty"`
|
|
}
|
|
|
|
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
|
|
type TasksView struct {
|
|
Status string `json:"status"`
|
|
Tasks []TaskView `json:"tasks,omitempty"`
|
|
}
|
|
|
|
// KnowledgeView projects the ephemeral planning knowledge state (the standing
|
|
// profile that grounds the coach). The profile text is intentionally omitted —
|
|
// only its presence, source path, and size cross the wire.
|
|
type KnowledgeView struct {
|
|
Status string `json:"status"`
|
|
Path string `json:"path,omitempty"`
|
|
Chars int `json:"chars,omitempty"`
|
|
}
|
|
|
|
// ReflectionView projects the reviewer's output. Recap is shown on Review;
|
|
// CarryForward is shown on the next Planning screen. Unlike the knowledge
|
|
// profile, these short lines exist to be displayed, so they cross the wire.
|
|
type ReflectionView struct {
|
|
Status string `json:"status,omitempty"`
|
|
Recap string `json:"recap,omitempty"`
|
|
CarryForward string `json:"carry_forward,omitempty"`
|
|
}
|
|
|
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
|
type WindowView struct {
|
|
Class string `json:"class"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type BucketView struct {
|
|
Class string `json:"class"`
|
|
Title string `json:"title"`
|
|
Seconds int64 `json:"seconds"`
|
|
}
|
|
|
|
type EvidenceView struct {
|
|
Available bool `json:"available"`
|
|
Reason string `json:"reason"`
|
|
Current WindowView `json:"current"`
|
|
SwitchCount int `json:"switch_count"`
|
|
Buckets []BucketView `json:"buckets"`
|
|
}
|
|
|
|
// State is the broadcastable view of the controller.
|
|
type State struct {
|
|
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
|
Commitment *CommitmentView `json:"commitment"`
|
|
Evidence *EvidenceView `json:"evidence"`
|
|
Coach *CoachView `json:"coach,omitempty"`
|
|
Tasks *TasksView `json:"tasks,omitempty"`
|
|
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
|
Reflection *ReflectionView `json:"reflection,omitempty"`
|
|
Drift *DriftView `json:"drift,omitempty"`
|
|
}
|
|
|
|
func (c *Controller) stateLocked() State {
|
|
st := State{RuntimeState: c.runtimeState}
|
|
if c.commitment != nil {
|
|
view := &CommitmentView{
|
|
NextAction: c.commitment.NextAction,
|
|
SuccessCondition: c.commitment.SuccessCondition,
|
|
TimeboxSecs: c.commitment.TimeboxSecs,
|
|
}
|
|
if !c.deadline.IsZero() {
|
|
view.DeadlineUnixSecs = c.deadline.Unix()
|
|
}
|
|
st.Commitment = view
|
|
}
|
|
if c.stats != nil {
|
|
st.Evidence = &EvidenceView{
|
|
Available: c.stats.Current.Health.Available,
|
|
Reason: c.stats.Current.Health.Reason,
|
|
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
|
|
SwitchCount: c.stats.SwitchCount,
|
|
Buckets: bucketViews(c.stats.Buckets),
|
|
}
|
|
}
|
|
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
|
|
}
|
|
cv := &CoachView{Status: status, Error: c.coachErr}
|
|
if c.coachProposal != nil {
|
|
cv.Proposal = &ProposalView{
|
|
NextAction: c.coachProposal.NextAction,
|
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
|
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
|
}
|
|
}
|
|
st.Coach = cv
|
|
if c.tasksProvider != nil {
|
|
tstatus := c.tasksStatus
|
|
if tstatus == "" {
|
|
tstatus = tasksIdle
|
|
}
|
|
tv := &TasksView{Status: tstatus}
|
|
for _, t := range c.tasksList {
|
|
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
|
|
}
|
|
st.Tasks = tv
|
|
}
|
|
if c.knowledgeSrc != nil {
|
|
kstatus := c.knowledgeStatus
|
|
if kstatus == "" {
|
|
kstatus = knowledgeIdle
|
|
}
|
|
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
|
|
}
|
|
if c.carryForward != "" {
|
|
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
|
|
}
|
|
}
|
|
if c.runtimeState == domain.RuntimeReview {
|
|
rstatus := c.reflectionStatus
|
|
if rstatus == "" {
|
|
rstatus = reflectionIdle
|
|
}
|
|
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
|
|
}
|
|
if c.runtimeState == domain.RuntimeActive {
|
|
status := c.driftStatus
|
|
if status == "" {
|
|
status = driftIdle
|
|
}
|
|
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
|
|
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
|
|
}
|
|
return st
|
|
}
|
|
|
|
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
|
|
out := make([]BucketView, 0, len(buckets))
|
|
for k, d := range buckets {
|
|
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
|
|
return out
|
|
}
|