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,158 @@
|
||||
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
|
||||
// off-screen action from the life-domain frame and files it on confirm. It is
|
||||
// one-shot — after confirm or dismiss it goes inactive and the harness releases
|
||||
// it.
|
||||
package offscreen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/tasks"
|
||||
)
|
||||
|
||||
const proposeTimeout = 60 * time.Second
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProposed = "proposed"
|
||||
statusError = "error"
|
||||
statusDone = "done"
|
||||
)
|
||||
|
||||
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
|
||||
type Mode struct {
|
||||
mu sync.Mutex
|
||||
async harness.Async
|
||||
ai *ai.Service
|
||||
tasks tasks.Provider
|
||||
know knowledge.Source // may be nil; assembleBrief must guard
|
||||
|
||||
status string
|
||||
gen int
|
||||
proposal *ai.OffscreenProposal
|
||||
errMsg string
|
||||
done bool
|
||||
}
|
||||
|
||||
var _ mode.Mode = (*Mode)(nil)
|
||||
|
||||
// New builds an off-screen mode from the shared services. It does not start the
|
||||
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
|
||||
func New(svc harness.Services) *Mode {
|
||||
m := &Mode{
|
||||
ai: svc.AI,
|
||||
tasks: svc.Tasks,
|
||||
know: svc.Knowledge,
|
||||
status: statusPending,
|
||||
}
|
||||
m.async = harness.NewAsync(&m.mu, svc.Notify)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mode) Kind() string { return "offscreen" }
|
||||
|
||||
// Active reports whether the mode still holds the harness. It goes false after
|
||||
// confirm or dismiss.
|
||||
func (m *Mode) Active() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return !m.done
|
||||
}
|
||||
|
||||
// Command routes a surface command. start (re)runs the brief; confirm files the
|
||||
// proposed action and finishes; dismiss finishes without writing.
|
||||
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
|
||||
switch name {
|
||||
case "start":
|
||||
return m.start()
|
||||
case "confirm":
|
||||
return m.confirm(ctx)
|
||||
case "dismiss":
|
||||
m.mu.Lock()
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("offscreen: unknown command %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// start assembles the brief and kicks off the brain asynchronously. A new call
|
||||
// bumps the generation so a stale in-flight result is discarded.
|
||||
func (m *Mode) start() error {
|
||||
m.mu.Lock()
|
||||
m.gen++
|
||||
gen := m.gen
|
||||
m.status = statusPending
|
||||
m.proposal = nil
|
||||
m.errMsg = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
brief := m.assembleBrief()
|
||||
|
||||
var p ai.OffscreenProposal
|
||||
var err error
|
||||
m.async.Run(proposeTimeout,
|
||||
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
|
||||
func() bool { return gen != m.gen },
|
||||
func() {
|
||||
if err != nil {
|
||||
m.status = statusError
|
||||
m.errMsg = err.Error()
|
||||
return
|
||||
}
|
||||
m.status = statusProposed
|
||||
m.proposal = &p
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirm files the proposed action as a task and ends the mode. It errors if
|
||||
// there is nothing to confirm yet.
|
||||
func (m *Mode) confirm(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
p := m.proposal
|
||||
m.mu.Unlock()
|
||||
if p == nil {
|
||||
return errors.New("offscreen: nothing to confirm")
|
||||
}
|
||||
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.status = statusDone
|
||||
m.done = true
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// View projects the mode's state for surfaces. Always JSON-marshalable.
|
||||
func (m *Mode) View() any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v := map[string]any{"status": m.status}
|
||||
if m.proposal != nil {
|
||||
v["next_action"] = m.proposal.NextAction
|
||||
v["rationale"] = m.proposal.Rationale
|
||||
}
|
||||
if m.errMsg != "" {
|
||||
v["error"] = m.errMsg
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
|
||||
func Factory(svc harness.Services) mode.Mode {
|
||||
m := New(svc)
|
||||
_ = m.start()
|
||||
return m
|
||||
}
|
||||
Reference in New Issue
Block a user