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:
+67
-89
@@ -7,15 +7,15 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/statemachine"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/mode/focus/statemachine"
|
||||
"keel/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Server wires the session controller to HTTP and SSE.
|
||||
// Server wires the harness to HTTP and SSE.
|
||||
type Server struct {
|
||||
ctrl *session.Controller
|
||||
h *harness.Harness
|
||||
bcast *Broadcaster
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -38,16 +38,20 @@ type Server struct {
|
||||
applyMu sync.Mutex // serializes POST /settings apply+save
|
||||
}
|
||||
|
||||
func NewServer(ctrl *session.Controller) *Server {
|
||||
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
|
||||
ctrl.SetOnChange(s.broadcast)
|
||||
// NewServer wires the harness to the SSE broadcaster: every harness change
|
||||
// (mode start, command, async role completion, expiry) fans out the current
|
||||
// state envelope to subscribers. The callback is registered additively, so the
|
||||
// status-file writer can register its own listener alongside this one.
|
||||
func NewServer(h *harness.Harness) *Server {
|
||||
s := &Server{h: h, bcast: NewBroadcaster()}
|
||||
h.AddOnChange(s.broadcast)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Router() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
// antidriftd binds localhost only and sits behind no proxy, so trust no
|
||||
// keeld binds localhost only and sits behind no proxy, so trust no
|
||||
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
@@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine {
|
||||
c.FileFromFS("/favicon.png", http.FS(sub))
|
||||
})
|
||||
r.GET("/events", s.handleEvents)
|
||||
r.POST("/planning", s.handlePlanning)
|
||||
r.POST("/coach", s.handleCoach)
|
||||
r.POST("/commitment", s.handleCommitment)
|
||||
r.POST("/complete", s.handleComplete)
|
||||
r.POST("/end", s.handleEnd)
|
||||
r.POST("/refocus", s.handleRefocus)
|
||||
r.POST("/ontask", s.handleOnTask)
|
||||
r.POST("/mode/:kind/start", s.handleStart)
|
||||
r.POST("/mode/command/:name", s.handleCommand)
|
||||
r.GET("/settings", s.handleGetSettings)
|
||||
r.POST("/settings", s.handlePostSettings)
|
||||
r.GET("/fs/browse", s.handleBrowse)
|
||||
@@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine {
|
||||
}
|
||||
|
||||
func (s *Server) stateJSON() string {
|
||||
data, _ := json.Marshal(s.ctrl.State())
|
||||
data, _ := json.Marshal(s.h.State())
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -90,80 +89,58 @@ func (s *Server) broadcast() {
|
||||
s.bcast.Publish(s.stateJSON())
|
||||
}
|
||||
|
||||
// respond maps a controller error to an HTTP status, otherwise broadcasts and
|
||||
// returns the new state.
|
||||
// respond maps a harness/mode error to an HTTP status, otherwise returns the
|
||||
// new state envelope. Broadcasting is handled solely by the harness onChange
|
||||
// listeners registered in NewServer; callers must not broadcast here.
|
||||
func (s *Server) respond(c *gin.Context, err error) {
|
||||
if err != nil {
|
||||
var illegal statemachine.IllegalTransitionError
|
||||
if errors.As(err, &illegal) {
|
||||
switch {
|
||||
case errors.As(err, &illegal):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, harness.ErrBusy):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, harness.ErrIdle):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, harness.ErrUnknownMode):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.broadcast()
|
||||
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
||||
}
|
||||
|
||||
func (s *Server) handlePlanning(c *gin.Context) {
|
||||
s.cancelExpiry()
|
||||
s.respond(c, s.ctrl.EnterPlanning())
|
||||
}
|
||||
|
||||
type coachRequest struct {
|
||||
Intent string `json:"intent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCoach(c *gin.Context) {
|
||||
var req coachRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
s.respond(c, s.ctrl.RequestCoach(req.Intent))
|
||||
}
|
||||
|
||||
type commitmentRequest struct {
|
||||
NextAction string `json:"next_action"`
|
||||
SuccessCondition string `json:"success_condition"`
|
||||
TimeboxSecs int64 `json:"timebox_secs"`
|
||||
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||
Enforce bool `json:"enforce"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCommitment(c *gin.Context) {
|
||||
var req commitmentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
level := domain.EnforcementWarn
|
||||
if req.Enforce {
|
||||
level = domain.EnforcementBlock
|
||||
}
|
||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
|
||||
if err == nil {
|
||||
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
|
||||
// case the mode is restored or transitions straight into a deadline-bearing
|
||||
// state; the arm is a no-op when there is no deadline yet.
|
||||
func (s *Server) handleStart(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
err := s.h.Start(kind)
|
||||
if err == nil && kind == "focus" {
|
||||
s.armExpiry()
|
||||
}
|
||||
s.respond(c, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleComplete(c *gin.Context) {
|
||||
s.cancelExpiry()
|
||||
s.respond(c, s.ctrl.Complete())
|
||||
}
|
||||
|
||||
func (s *Server) handleEnd(c *gin.Context) {
|
||||
s.respond(c, s.ctrl.End())
|
||||
}
|
||||
|
||||
func (s *Server) handleRefocus(c *gin.Context) {
|
||||
s.respond(c, s.ctrl.Refocus())
|
||||
}
|
||||
|
||||
func (s *Server) handleOnTask(c *gin.Context) {
|
||||
s.respond(c, s.ctrl.OnTask())
|
||||
// handleCommand routes a surface command to the active mode and manages the
|
||||
// expiry timer for the focus lifecycle: a commitment arms it, completing or
|
||||
// ending cancels it. The raw body is forwarded as the command payload; modes
|
||||
// decode and validate it themselves.
|
||||
func (s *Server) handleCommand(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
err := s.h.Command(c.Request.Context(), name, body)
|
||||
switch name {
|
||||
case "commitment":
|
||||
if err == nil {
|
||||
s.armExpiry()
|
||||
}
|
||||
case "complete", "end":
|
||||
s.cancelExpiry()
|
||||
}
|
||||
s.respond(c, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(c *gin.Context) {
|
||||
@@ -196,9 +173,12 @@ func (s *Server) handleEvents(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// armExpiry schedules an Active -> Review transition at the commitment deadline.
|
||||
// armExpiry schedules an Active -> Review transition at the active mode's
|
||||
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
|
||||
// Expire notifies harness listeners, which broadcasts the new state via the
|
||||
// callback registered in NewServer.
|
||||
func (s *Server) armExpiry() {
|
||||
deadline := s.ctrl.Deadline()
|
||||
deadline := s.h.Deadline()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
@@ -212,9 +192,7 @@ func (s *Server) armExpiry() {
|
||||
d = 0
|
||||
}
|
||||
s.timer = time.AfterFunc(d, func() {
|
||||
if err := s.ctrl.Expire(); err == nil {
|
||||
s.broadcast()
|
||||
}
|
||||
_ = s.h.Expire()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,17 +205,17 @@ func (s *Server) cancelExpiry() {
|
||||
}
|
||||
}
|
||||
|
||||
// Init re-arms or expires a restored Active session at startup.
|
||||
// Init re-arms or expires a restored deadline-bearing session at startup. With
|
||||
// no active mode the deadline is zero, so this is a no-op until a session is
|
||||
// adopted.
|
||||
func (s *Server) Init() {
|
||||
st := s.ctrl.State()
|
||||
if st.RuntimeState != "active" {
|
||||
deadline := s.h.Deadline()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
if s.ctrl.Deadline().After(time.Now()) {
|
||||
if deadline.After(time.Now()) {
|
||||
s.armExpiry()
|
||||
return
|
||||
}
|
||||
if err := s.ctrl.Expire(); err == nil {
|
||||
s.broadcast()
|
||||
}
|
||||
_ = s.h.Expire()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user