Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d1212fd
autoresearch: cycle 2 — wire Config into state.Manager with Get/SetDe…
bearmug Mar 24, 2026
f42ff55
autoresearch: cycle 3 — apply default autopilot in RegisterSession fo…
bearmug Mar 24, 2026
b2238b3
autoresearch: cycle 4 — apply default autopilot in UpdateSessionFromS…
bearmug Mar 24, 2026
ee2df10
autoresearch: cycles 5+6 — add set_default_autopilot and get_config a…
bearmug Mar 24, 2026
d42cce0
autoresearch: cycle 7 — add SetDefaultAutopilot and GetConfig client …
bearmug Mar 24, 2026
c8b2319
autoresearch: cycle 8 — include default_autopilot in state snapshot s…
bearmug Mar 24, 2026
ea3f24f
autoresearch: cycle 10 — add tests for default autopilot behavior
bearmug Mar 24, 2026
ef482d7
autoresearch: add 'd' key binding to cycle default autopilot mode
bearmug Mar 24, 2026
68a38f0
autoresearch: show default autopilot indicator in status bar
bearmug Mar 24, 2026
2756fc3
autoresearch: annotate autopilot badge with '(default)' in zoom panel
bearmug Mar 24, 2026
c5dec33
autoresearch: add 'd default' hint to session hints bar
bearmug Mar 24, 2026
a895a44
autoresearch: document 'd' key and default autopilot behavior in help…
bearmug Mar 24, 2026
46e5728
autoresearch: add first-run discoverability tip in empty state
bearmug Mar 24, 2026
845e5ea
autoresearch: refine flash message styling for default autopilot toggle
bearmug Mar 24, 2026
a4720da
autoresearch: fix zoom_test.go calls after renderZoom signature change
bearmug Mar 24, 2026
c528b2c
autoresearch: finalize cycle log for agent-c default autopilot UX
bearmug Mar 24, 2026
53fcd27
autoresearch: cycle 1 — add Config struct and config load/save helpers
bearmug Mar 24, 2026
0c53f9d
autoresearch: add defaultAutopilot field to Model struct
bearmug Mar 24, 2026
105787e
Wire TUI default autopilot to daemon client
bearmug Mar 24, 2026
c5113f1
Remove .autoresearch artifacts from tracking
bearmug Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions daemon/internal/ctlserver/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,22 @@ import (
)

type ctlRequest struct {
Action string `json:"action"`
SessionID string `json:"session_id,omitempty"`
PRURL string `json:"pr_url,omitempty"` // for add_pr
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method
MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method
Action string `json:"action"`
SessionID string `json:"session_id,omitempty"`
PRURL string `json:"pr_url,omitempty"` // for add_pr
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method
MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method
DefaultAutopilot string `json:"default_autopilot,omitempty"` // for set_default_autopilot
}

type ctlResponse struct {
OK *bool `json:"ok,omitempty"`
Sessions []model.Session `json:"sessions,omitempty"`
PRs []pr.TrackedPR `json:"prs,omitempty"`
Event string `json:"event,omitempty"`
AutopilotMode string `json:"autopilot_mode,omitempty"`
NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo
OK *bool `json:"ok,omitempty"`
Sessions []model.Session `json:"sessions,omitempty"`
PRs []pr.TrackedPR `json:"prs,omitempty"`
Event string `json:"event,omitempty"`
AutopilotMode string `json:"autopilot_mode,omitempty"`
NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo
DefaultAutopilot string `json:"default_autopilot,omitempty"`
}

type Handler struct {
Expand Down Expand Up @@ -76,6 +78,10 @@ func (h *Handler) Handle(conn net.Conn) {
h.handleSetMergeMethod(conn, req.PRKey, req.MergeMethod)
case "toggle_review":
h.handleToggleReview(conn, req.PRKey)
case "set_default_autopilot":
h.handleSetDefaultAutopilot(conn, req.DefaultAutopilot)
case "get_config":
h.handleGetConfig(conn)
}
}
}
Expand All @@ -102,8 +108,9 @@ func (h *Handler) handleSubscribe(conn net.Conn) {

func (h *Handler) stateSnapshot() ctlResponse {
resp := ctlResponse{
Event: "state_updated",
Sessions: h.state.GetSessions(),
Event: "state_updated",
Sessions: h.state.GetSessions(),
DefaultAutopilot: h.state.GetDefaultAutopilot(),
}
if h.prPoll != nil {
resp.PRs = h.prPoll.GetAll()
Expand Down Expand Up @@ -274,6 +281,18 @@ func (h *Handler) handleToggleReview(conn net.Conn, key string) {
writeJSON(conn, ctlResponse{OK: &ok})
}

func (h *Handler) handleSetDefaultAutopilot(conn net.Conn, mode string) {
h.state.SetDefaultAutopilot(mode)
ok := true
writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode})
}

func (h *Handler) handleGetConfig(conn net.Conn) {
mode := h.state.GetDefaultAutopilot()
ok := true
writeJSON(conn, ctlResponse{OK: &ok, DefaultAutopilot: mode})
}

func writeJSON(conn net.Conn, v any) {
data, _ := json.Marshal(v)
data = append(data, '\n')
Expand Down
59 changes: 59 additions & 0 deletions daemon/internal/state/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package state

import (
"encoding/json"
"log"
"os"
"path/filepath"
)

// Config holds daemon-wide configuration persisted to ~/.csm/config.json.
type Config struct {
// DefaultAutopilot is the autopilot mode applied to newly discovered
// sessions that have no persisted per-session override.
// Valid values: "" (none), "on", "yolo".
DefaultAutopilot string `json:"default_autopilot"`
}

// configPath returns the path to the config file (~/.csm/config.json).
func configPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".csm", "config.json")
}

// loadConfig reads ~/.csm/config.json and returns the parsed Config.
// Missing file is treated as empty Config (zero value).
func loadConfig(path string) Config {
if path == "" {
return Config{}
}
data, err := os.ReadFile(path)
if err != nil {
// File not found is normal on first run.
return Config{}
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
log.Printf("state: failed to parse config %s: %v", path, err)
return Config{}
}
return cfg
}

// saveConfig writes cfg to the given path as JSON.
func saveConfig(path string, cfg Config) {
if path == "" {
return
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
log.Printf("state: failed to marshal config: %v", err)
return
}
if err := os.WriteFile(path, data, 0o644); err != nil {
log.Printf("state: failed to save config to %s: %v", path, err)
}
}
53 changes: 51 additions & 2 deletions daemon/internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ type Manager struct {
subMu sync.Mutex

autopilotPath string

// config holds daemon-wide settings (e.g., default autopilot mode).
config Config
// configFilePath is the path to ~/.csm/config.json (empty = no persistence).
configFilePath string
}

// New creates a new state Manager, loading persisted autopilot state.
Expand All @@ -61,6 +66,8 @@ func New() *Manager {
_ = os.MkdirAll(dir, 0o755)
m.autopilotPath = filepath.Join(dir, "autopilot.json")
m.loadAutopilot()
m.configFilePath = filepath.Join(dir, "config.json")
m.config = loadConfig(m.configFilePath)
}

return m
Expand All @@ -79,6 +86,8 @@ func NewWithDir(dir string) *Manager {
_ = os.MkdirAll(dir, 0o755)
m.autopilotPath = filepath.Join(dir, "autopilot.json")
m.loadAutopilot()
m.configFilePath = filepath.Join(dir, "config.json")
m.config = loadConfig(m.configFilePath)
}
return m
}
Expand All @@ -101,9 +110,12 @@ func (m *Manager) RegisterSession(sid, cwd, permMode string) {
s.PermissionMode = permMode
s.ProjectName = filepath.Base(cwd)

// Restore persisted autopilot state.
// Restore persisted autopilot state; fall back to config default for new sessions.
if mode, ok := m.autopilot[sid]; ok && mode != "" {
s.AutopilotMode = mode
} else if !exists && m.config.DefaultAutopilot != "" {
s.AutopilotMode = m.config.DefaultAutopilot
log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, sid)
}

m.notifySubscribers()
Expand All @@ -123,16 +135,32 @@ func (m *Manager) UnregisterSession(sid string) {
m.notifySubscribers()
}

// applyDefaultAutopilot sets the session's AutopilotMode to the configured
// default when the session has no persisted per-session mode. Caller must
// hold m.mu (write lock).
func (m *Manager) applyDefaultAutopilot(s *model.Session) {
if _, hasPersisted := m.autopilot[s.SessionID]; hasPersisted {
// Per-session persisted state already handled separately; skip.
return
}
if s.AutopilotMode == "" && m.config.DefaultAutopilot != "" {
s.AutopilotMode = m.config.DefaultAutopilot
log.Printf("state: applied default autopilot %s to session %s", m.config.DefaultAutopilot, s.SessionID)
}
}

// UpdateSessionFromScanner merges scanner-discovered session data.
func (m *Manager) UpdateSessionFromScanner(s *model.Session) {
m.mu.Lock()
defer m.mu.Unlock()

existing, ok := m.sessions[s.SessionID]
if !ok {
// New session from scanner.
// New session from scanner — restore persisted mode or apply default.
if mode, okAP := m.autopilot[s.SessionID]; okAP && mode != "" {
s.AutopilotMode = mode
} else {
m.applyDefaultAutopilot(s)
}
m.sessions[s.SessionID] = s
m.notifySubscribers()
Expand Down Expand Up @@ -467,3 +495,24 @@ func (m *Manager) saveAutopilot() {
log.Printf("failed to save autopilot state: %v", err)
}
}

// GetDefaultAutopilot returns the daemon-wide default autopilot mode.
// Empty string means no default (sessions start with autopilot off).
func (m *Manager) GetDefaultAutopilot() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.config.DefaultAutopilot
}

// SetDefaultAutopilot updates the daemon-wide default autopilot mode and
// persists it to ~/.csm/config.json. Valid values: "" (none), "on", "yolo".
func (m *Manager) SetDefaultAutopilot(mode string) {
m.mu.Lock()
m.config.DefaultAutopilot = mode
cfgPath := m.configFilePath
cfg := m.config
m.mu.Unlock()

saveConfig(cfgPath, cfg)
log.Printf("state: default autopilot set to %q", mode)
}
111 changes: 111 additions & 0 deletions daemon/internal/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -858,3 +858,114 @@ func TestResolvePendingClearsPendingTools(t *testing.T) {
t.Error("HasDestructive should be false after resolve")
}
}

// --- Default autopilot ---

// newTestManagerWithDefault creates a Manager without disk persistence but
// with a pre-configured default autopilot mode.
func newTestManagerWithDefault(defaultMode string) *Manager {
return &Manager{
sessions: make(map[string]*model.Session),
autopilot: make(map[string]string),
pending: make(map[string]*model.PendingApproval),
cooldowns: make(map[string]time.Time),
config: Config{DefaultAutopilot: defaultMode},
}
}

// TestDefaultAutopilotAppliedOnRegister verifies that new sessions receive the
// default autopilot mode when no per-session override exists.
func TestDefaultAutopilotAppliedOnRegister(t *testing.T) {
m := newTestManagerWithDefault(model.AutopilotYolo)
m.RegisterSession("s1", "/path", "default")

sessions := m.GetSessions()
if sessions[0].AutopilotMode != model.AutopilotYolo {
t.Errorf("autopilot = %q, want yolo (default)", sessions[0].AutopilotMode)
}
}

// TestPersistedAutopilotOverridesDefault verifies that a persisted per-session
// mode takes priority over the daemon-wide default.
func TestPersistedAutopilotOverridesDefault(t *testing.T) {
m := newTestManagerWithDefault(model.AutopilotYolo)
m.autopilot["s1"] = model.AutopilotOn // persisted override
m.RegisterSession("s1", "/path", "default")

sessions := m.GetSessions()
if sessions[0].AutopilotMode != model.AutopilotOn {
t.Errorf("autopilot = %q, want on (persisted should override default yolo)", sessions[0].AutopilotMode)
}
}

// TestSetDefaultAutopilotPersistsToDisk verifies that SetDefaultAutopilot
// writes to disk and is readable by a new manager loaded from the same dir.
func TestSetDefaultAutopilotPersistsToDisk(t *testing.T) {
dir := t.TempDir()
m := NewWithDir(dir)
m.SetDefaultAutopilot(model.AutopilotYolo)

// New manager from same dir should load the persisted default.
m2 := NewWithDir(dir)
if got := m2.GetDefaultAutopilot(); got != model.AutopilotYolo {
t.Errorf("GetDefaultAutopilot = %q, want yolo", got)
}
}

// TestDefaultAutopilotAppliedOnScanner verifies that scanner-discovered
// sessions also receive the default autopilot mode.
func TestDefaultAutopilotAppliedOnScanner(t *testing.T) {
m := newTestManagerWithDefault(model.AutopilotOn)
s := &model.Session{SessionID: "scan-1", CWD: "/path", State: model.StateRunning, PID: 1}
m.UpdateSessionFromScanner(s)

sessions := m.GetSessions()
if sessions[0].AutopilotMode != model.AutopilotOn {
t.Errorf("scanner session autopilot = %q, want on (default)", sessions[0].AutopilotMode)
}
}

// TestDefaultNotAppliedWhenSessionAlreadyRegistered verifies that when a
// session is first registered via hook (getting the default), a subsequent
// scanner update does not clobber the mode.
func TestDefaultNotAppliedWhenSessionAlreadyRegistered(t *testing.T) {
m := newTestManagerWithDefault(model.AutopilotOn)
// Hook registers the session; default is applied.
m.RegisterSession("s1", "/path", "default")

// User later cycles to yolo.
m.CycleAutopilot("s1") // on → yolo (since default applied "on" first)
// Actually start from registered state: default=on → cycle → yolo.
sessions := m.GetSessions()
if sessions[0].AutopilotMode != model.AutopilotYolo {
t.Fatalf("after cycle: want yolo, got %q", sessions[0].AutopilotMode)
}

// Now scanner update comes in for the existing session — should NOT reset mode.
s := &model.Session{SessionID: "s1", CWD: "/scanner/path", State: model.StateRunning, PID: 999}
m.UpdateSessionFromScanner(s)

sessions = m.GetSessions()
if sessions[0].AutopilotMode != model.AutopilotYolo {
t.Errorf("scanner update clobbered autopilot: got %q, want yolo", sessions[0].AutopilotMode)
}
}

// TestGetSetDefaultAutopilot verifies the get/set round-trip in-memory.
func TestGetSetDefaultAutopilot(t *testing.T) {
m := newTestManager()

if got := m.GetDefaultAutopilot(); got != "" {
t.Errorf("initial default = %q, want empty", got)
}

m.SetDefaultAutopilot(model.AutopilotOn)
if got := m.GetDefaultAutopilot(); got != model.AutopilotOn {
t.Errorf("after set: default = %q, want on", got)
}

m.SetDefaultAutopilot("")
if got := m.GetDefaultAutopilot(); got != "" {
t.Errorf("after clear: default = %q, want empty", got)
}
}
Loading
Loading