// 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 }