/ internal / daemon / server.go
server.go
   1  package daemon
   2  
   3  import (
   4  	"context"
   5  	"encoding/json"
   6  	"errors"
   7  	"fmt"
   8  	"io"
   9  	"log"
  10  	"maps"
  11  	"net"
  12  	"net/http"
  13  	"os"
  14  	"path/filepath"
  15  	"regexp"
  16  	"slices"
  17  	"sort"
  18  	"strconv"
  19  	"strings"
  20  	"sync"
  21  	"time"
  22  
  23  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
  24  	"github.com/Kocoro-lab/ShanClaw/internal/agents"
  25  	"github.com/Kocoro-lab/ShanClaw/internal/audit"
  26  	"github.com/Kocoro-lab/ShanClaw/internal/client"
  27  	"github.com/Kocoro-lab/ShanClaw/internal/config"
  28  	ctxwin "github.com/Kocoro-lab/ShanClaw/internal/context"
  29  	"github.com/Kocoro-lab/ShanClaw/internal/mcp"
  30  	"github.com/Kocoro-lab/ShanClaw/internal/memory"
  31  	"github.com/Kocoro-lab/ShanClaw/internal/permissions"
  32  	"github.com/Kocoro-lab/ShanClaw/internal/schedule"
  33  	"github.com/Kocoro-lab/ShanClaw/internal/session"
  34  	"github.com/Kocoro-lab/ShanClaw/internal/skills"
  35  	syncpkg "github.com/Kocoro-lab/ShanClaw/internal/sync"
  36  	"github.com/Kocoro-lab/ShanClaw/internal/tools"
  37  	"github.com/spf13/viper"
  38  	"gopkg.in/yaml.v3"
  39  )
  40  
  41  type Server struct {
  42  	port                   int
  43  	client                 *Client
  44  	deps                   *ServerDeps
  45  	server                 *http.Server
  46  	listenerMu             sync.Mutex // protects listener
  47  	listener               net.Listener
  48  	version                string
  49  	ctx                    context.Context // daemon lifecycle context, set on Start
  50  	cancel                 context.CancelFunc
  51  	approvalBroker         *ApprovalBroker
  52  	eventBus               *EventBus
  53  	notifyApprovalResolved func(p ApprovalResolvedPayload) error
  54  	// pendingBrokers maps requestID → per-request ApprovalBroker.
  55  	// SSE handlers register here so POST /approval can find the right broker.
  56  	pendingBrokers sync.Map // map[string]*ApprovalBroker
  57  	onReload       func()   // called after config reload to restart watchers/heartbeat
  58  
  59  	marketplace  *skills.MarketplaceClient
  60  	slugLocks    *skills.SlugLocks
  61  	secretsStore *skills.SecretsStore
  62  	memSvc       *memory.Service
  63  }
  64  
  65  // requireDeps returns true if s.deps is non-nil, otherwise writes a 500
  66  // and returns false. Marketplace handlers dereference s.deps.ShannonDir
  67  // and s.deps.AgentsDir; without this guard they'd panic when the server
  68  // is constructed with nil deps (which some existing tests and callers
  69  // do — NewServer stays nil-safe via resolveRegistryURL below, so the
  70  // handlers must match that contract).
  71  func (s *Server) requireDeps(w http.ResponseWriter) bool {
  72  	if s == nil || s.deps == nil {
  73  		writeError(w, http.StatusInternalServerError, "daemon not fully initialized")
  74  		return false
  75  	}
  76  	return true
  77  }
  78  
  79  // auditHTTPOp logs an HTTP API write operation to the audit log.
  80  func (s *Server) auditHTTPOp(method, path, summary string) {
  81  	if s.deps == nil || s.deps.Auditor == nil {
  82  		return
  83  	}
  84  	s.deps.Auditor.Log(audit.AuditEntry{
  85  		Timestamp:    time.Now(),
  86  		ToolName:     "http_api",
  87  		InputSummary: method + " " + path + ": " + summary,
  88  		Decision:     "approved",
  89  		Approved:     true,
  90  	})
  91  }
  92  
  93  // resolveRegistryURL returns the configured marketplace registry URL, falling
  94  // back to the public default. Tolerates nil deps / nil Config so tests that
  95  // construct NewServer with nil deps continue to work.
  96  func resolveRegistryURL(deps *ServerDeps) string {
  97  	const defaultURL = "https://raw.githubusercontent.com/Kocoro-lab/shanclaw-skill-registry/main/index.json"
  98  	if deps == nil || deps.Config == nil {
  99  		return defaultURL
 100  	}
 101  	if u := deps.Config.Skills.Marketplace.RegistryURL; u != "" {
 102  		return u
 103  	}
 104  	return defaultURL
 105  }
 106  
 107  var (
 108  	showChromeOnPortFn        = mcp.ShowCDPChromeOnPort
 109  	hideChromeOnPortFn        = mcp.HideCDPChromeOnPort
 110  	getChromeStatusOnPortFn   = mcp.GetCDPChromeStatusOnPort
 111  	getChromeProfileStateFn   = mcp.GetChromeProfileState
 112  	stopChromeFn              = mcp.StopCDPChrome
 113  	resetChromeProfileCloneFn = mcp.ResetCDPProfileClone
 114  )
 115  
 116  func NewServer(port int, client *Client, deps *ServerDeps, version string) *Server {
 117  	var shannonDir string
 118  	if deps != nil {
 119  		shannonDir = deps.ShannonDir
 120  	}
 121  	store := skills.NewSecretsStore(shannonDir)
 122  	if deps != nil {
 123  		deps.SecretsStore = store
 124  	}
 125  	return &Server{
 126  		port:                   port,
 127  		client:                 client,
 128  		deps:                   deps,
 129  		version:                version,
 130  		approvalBroker:         NewApprovalBroker(func(req ApprovalRequest) error { return nil }),
 131  		eventBus:               NewEventBus(),
 132  		notifyApprovalResolved: func(p ApprovalResolvedPayload) error { return nil },
 133  		marketplace:            skills.NewMarketplaceClient(resolveRegistryURL(deps), 1*time.Hour),
 134  		slugLocks:              skills.NewSlugLocks(),
 135  		secretsStore:           store,
 136  	}
 137  }
 138  
 139  func (s *Server) chromeControlPort() int {
 140  	if s == nil || s.deps == nil {
 141  		return mcp.DefaultCDPPort
 142  	}
 143  	cfg, _, _ := s.deps.Snapshot()
 144  	if cfg == nil || cfg.MCPServers == nil {
 145  		return mcp.DefaultCDPPort
 146  	}
 147  	playwright, ok := cfg.MCPServers["playwright"]
 148  	if !ok {
 149  		return mcp.DefaultCDPPort
 150  	}
 151  	return mcp.PlaywrightCDPPort(mcp.NormalizePlaywrightCDPConfig(playwright))
 152  }
 153  
 154  func (s *Server) configuredChromeProfile() string {
 155  	if s == nil || s.deps == nil {
 156  		return ""
 157  	}
 158  	cfg, _, _ := s.deps.Snapshot()
 159  	if cfg == nil {
 160  		return ""
 161  	}
 162  	return cfg.Daemon.ChromeProfile
 163  }
 164  
 165  func (s *Server) setConfiguredChromeProfile(profile string) {
 166  	if s == nil || s.deps == nil {
 167  		return
 168  	}
 169  	s.deps.WriteLock()
 170  	if s.deps.Config == nil {
 171  		s.deps.Config = &config.Config{}
 172  	}
 173  	s.deps.Config.Daemon.ChromeProfile = profile
 174  	mcp.SetCDPChromeProfile(profile)
 175  	s.deps.WriteUnlock()
 176  }
 177  
 178  // SetApprovalResolvedNotifier sets the function called to notify Cloud when
 179  // Ptfrog resolves an approval before the external channel does.
 180  func (s *Server) SetApprovalResolvedNotifier(fn func(ApprovalResolvedPayload) error) {
 181  	s.notifyApprovalResolved = fn
 182  }
 183  
 184  func (s *Server) Port() int {
 185  	s.listenerMu.Lock()
 186  	ln := s.listener
 187  	s.listenerMu.Unlock()
 188  	if ln != nil {
 189  		return ln.Addr().(*net.TCPAddr).Port
 190  	}
 191  	return s.port
 192  }
 193  
 194  // SetCancelFunc sets a cancel function that handleShutdown will call to stop the daemon.
 195  func (s *Server) SetCancelFunc(cancel context.CancelFunc) {
 196  	s.cancel = cancel
 197  }
 198  
 199  // SetOnReload sets a callback invoked after config reload to restart watchers/heartbeat.
 200  func (s *Server) SetOnReload(fn func()) {
 201  	s.onReload = fn
 202  }
 203  
 204  func (s *Server) Start(ctx context.Context) error {
 205  	s.ctx = ctx
 206  	mux := http.NewServeMux()
 207  	mux.HandleFunc("GET /health", s.handleHealth)
 208  	mux.HandleFunc("GET /status", s.handleStatus)
 209  	mux.HandleFunc("GET /agents", s.handleAgents)
 210  	mux.HandleFunc("GET /agents/{name}", s.handleGetAgent)
 211  	mux.HandleFunc("POST /agents", s.handleCreateAgent)
 212  	mux.HandleFunc("PUT /agents/{name}", s.handleUpdateAgent)
 213  	mux.HandleFunc("DELETE /agents/{name}", s.handleDeleteAgent)
 214  	mux.HandleFunc("PUT /agents/{name}/config", s.handlePutAgentConfig)
 215  	mux.HandleFunc("DELETE /agents/{name}/config", s.handleDeleteAgentConfig)
 216  	mux.HandleFunc("PUT /agents/{name}/commands/{cmd}", s.handlePutCommand)
 217  	mux.HandleFunc("DELETE /agents/{name}/commands/{cmd}", s.handleDeleteCommand)
 218  	mux.HandleFunc("PUT /agents/{name}/skills/{skill}", s.handlePutSkill)
 219  	mux.HandleFunc("DELETE /agents/{name}/skills/{skill}", s.handleDeleteSkill)
 220  	mux.HandleFunc("GET /skills/downloadable", s.handleListDownloadableSkills)
 221  	mux.HandleFunc("POST /skills/install/{name}", s.handleInstallSkill)
 222  	mux.HandleFunc("POST /skills/marketplace/install/{slug}", s.handleMarketplaceInstall)
 223  	mux.HandleFunc("GET /skills/marketplace", s.handleMarketplaceList)
 224  	mux.HandleFunc("GET /skills/marketplace/entry/{slug}", s.handleMarketplaceDetail)
 225  	mux.HandleFunc("GET /skills", s.handleListSkills)
 226  	mux.HandleFunc("GET /skills/{name}", s.handleGetSkill)
 227  	mux.HandleFunc("PUT /skills/{name}", s.handlePutGlobalSkill)
 228  	mux.HandleFunc("DELETE /skills/{name}", s.handleDeleteGlobalSkill)
 229  	mux.HandleFunc("GET /skills/{name}/scripts", s.handleListSkillScripts)
 230  	mux.HandleFunc("PUT /skills/{name}/scripts/{filename}", s.handlePutSkillScripts)
 231  	mux.HandleFunc("DELETE /skills/{name}/scripts/{filename}", s.handleDeleteSkillScripts)
 232  	mux.HandleFunc("PUT /skills/{name}/secrets", s.handlePutSkillSecrets)
 233  	mux.HandleFunc("DELETE /skills/{name}/secrets", s.handleDeleteSkillSecrets)
 234  	mux.HandleFunc("DELETE /skills/{name}/secrets/{key}", s.handleDeleteSkillSecretKey)
 235  	mux.HandleFunc("GET /skills/{name}/references", s.handleListSkillReferences)
 236  	mux.HandleFunc("PUT /skills/{name}/references/{filename}", s.handlePutSkillReferences)
 237  	mux.HandleFunc("DELETE /skills/{name}/references/{filename}", s.handleDeleteSkillReferences)
 238  	mux.HandleFunc("GET /skills/{name}/assets", s.handleListSkillAssets)
 239  	mux.HandleFunc("GET /skills/{name}/usage", s.handleSkillUsage)
 240  	mux.HandleFunc("PUT /skills/{name}/assets/{filename}", s.handlePutSkillAssets)
 241  	mux.HandleFunc("DELETE /skills/{name}/assets/{filename}", s.handleDeleteSkillAssets)
 242  	mux.HandleFunc("GET /schedules", s.handleListSchedules)
 243  	mux.HandleFunc("GET /schedules/{id}", s.handleGetSchedule)
 244  	mux.HandleFunc("POST /schedules", s.handleCreateSchedule)
 245  	mux.HandleFunc("PATCH /schedules/{id}", s.handlePatchSchedule)
 246  	mux.HandleFunc("DELETE /schedules/{id}", s.handleDeleteSchedule)
 247  	mux.HandleFunc("GET /config", s.handleGetConfig)
 248  	mux.HandleFunc("GET /config/status", s.handleConfigStatus)
 249  	mux.HandleFunc("PATCH /config", s.handlePatchConfig)
 250  	mux.HandleFunc("POST /config/reload", s.handleConfigReload)
 251  	mux.HandleFunc("GET /instructions", s.handleGetInstructions)
 252  	mux.HandleFunc("PUT /instructions", s.handlePutInstructions)
 253  	mux.HandleFunc("GET /rules", s.handleListRules)
 254  	mux.HandleFunc("GET /rules/{name}", s.handleGetRule)
 255  	mux.HandleFunc("PUT /rules/{name}", s.handlePutRule)
 256  	mux.HandleFunc("DELETE /rules/{name}", s.handleDeleteRule)
 257  	mux.HandleFunc("POST /project/init", s.handleProjectInit)
 258  	mux.HandleFunc("GET /sessions", s.handleSessions)
 259  	mux.HandleFunc("GET /sessions/{id}", s.handleGetSession)
 260  	mux.HandleFunc("DELETE /sessions/{id}", s.handleDeleteSession)
 261  	mux.HandleFunc("PATCH /sessions/{id}", s.handlePatchSession)
 262  	mux.HandleFunc("POST /sessions/{id}/edit", s.handleEditMessage)
 263  	mux.HandleFunc("POST /sessions/{id}/reset", s.handleResetSession)
 264  	mux.HandleFunc("GET /sessions/{id}/summary", s.handleSessionSummary)
 265  	mux.HandleFunc("GET /sessions/search", s.handleSessionSearch)
 266  	mux.HandleFunc("GET /permissions", s.handlePermissions)
 267  	mux.HandleFunc("POST /permissions/request", s.handlePermissionsRequest)
 268  	mux.HandleFunc("POST /approval", s.handleApproval)
 269  	mux.HandleFunc("POST /message", s.handleMessage)
 270  	mux.HandleFunc("POST /cancel", s.handleCancel)
 271  	mux.HandleFunc("GET /events", s.handleEvents)
 272  	mux.HandleFunc("GET /chrome/status", s.handleChromeStatus)
 273  	mux.HandleFunc("GET /chrome/profile", s.handleChromeProfile)
 274  	mux.HandleFunc("POST /chrome/profile", s.handleChromeProfileUpdate)
 275  	mux.HandleFunc("POST /chrome/profile/refresh", s.handleChromeProfileRefresh)
 276  	mux.HandleFunc("POST /chrome/show", s.handleChromeShow)
 277  	mux.HandleFunc("POST /chrome/hide", s.handleChromeHide)
 278  	mux.HandleFunc("POST /shutdown", s.handleShutdown)
 279  
 280  	ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", s.port))
 281  	if err != nil {
 282  		return fmt.Errorf("daemon server listen: %w", err)
 283  	}
 284  	s.listenerMu.Lock()
 285  	s.listener = ln
 286  	s.listenerMu.Unlock()
 287  	s.server = &http.Server{Handler: mux}
 288  
 289  	// Spawn the gated session-sync ticker. It self-disables when sync is
 290  	// not enabled, so it's always safe to start unconditionally here.
 291  	go s.runSyncLoop(ctx)
 292  
 293  	// Memory feature (Phase 2.3). Service is constructed once and Start runs
 294  	// the cold-path gates synchronously then spawns the supervisor goroutine.
 295  	// Failure modes (provider=disabled, tlm missing, cloud misconfigured) all
 296  	// resolve to Status=Disabled/Unavailable; the memory tool falls back.
 297  	memCfg := memory.LoadConfig(viper.GetViper())
 298  	memCfg.APIKey = memory.ResolveAPIKey(viper.GetViper())
 299  	memCfg.Endpoint = memory.ResolveEndpoint(viper.GetViper())
 300  	var memAudit memory.AuditLogger
 301  	if s.deps != nil && s.deps.Auditor != nil {
 302  		memAudit = memoryAuditAdapter{logger: s.deps.Auditor}
 303  	}
 304  	s.memSvc = memory.NewService(memCfg, memAudit)
 305  	if s.deps != nil {
 306  		s.deps.MemSvc = s.memSvc
 307  	}
 308  	go func() {
 309  		if err := s.memSvc.Start(ctx); err != nil {
 310  			log.Printf("daemon memory: start error: %v", err)
 311  		}
 312  	}()
 313  
 314  	go func() {
 315  		<-ctx.Done()
 316  		// Stop the memory sidecar before HTTP shutdown so SIGTERM reaches the
 317  		// child process while the daemon is still alive to drain its exit.
 318  		if s.memSvc != nil {
 319  			_ = s.memSvc.Stop()
 320  		}
 321  		shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 322  		defer cancel()
 323  		s.server.Shutdown(shutCtx)
 324  	}()
 325  
 326  	if err := s.server.Serve(ln); err != http.ErrServerClosed {
 327  		return err
 328  	}
 329  	return nil
 330  }
 331  
 332  func (s *Server) handleChromeShow(w http.ResponseWriter, r *http.Request) {
 333  	w.Header().Set("Content-Type", "application/json")
 334  	if err := showChromeOnPortFn(s.chromeControlPort()); err != nil {
 335  		if errors.Is(err, mcp.ErrChromeNotRunning) {
 336  			w.WriteHeader(http.StatusNotFound)
 337  			json.NewEncoder(w).Encode(map[string]string{"error": "chrome_not_running"})
 338  		} else {
 339  			w.WriteHeader(http.StatusInternalServerError)
 340  			json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
 341  		}
 342  		return
 343  	}
 344  	json.NewEncoder(w).Encode(map[string]string{"status": "visible"})
 345  }
 346  
 347  func (s *Server) handleChromeHide(w http.ResponseWriter, r *http.Request) {
 348  	w.Header().Set("Content-Type", "application/json")
 349  	if err := hideChromeOnPortFn(s.chromeControlPort()); err != nil {
 350  		if errors.Is(err, mcp.ErrChromeNotRunning) {
 351  			w.WriteHeader(http.StatusNotFound)
 352  			json.NewEncoder(w).Encode(map[string]string{"error": "chrome_not_running"})
 353  		} else {
 354  			w.WriteHeader(http.StatusInternalServerError)
 355  			json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
 356  		}
 357  		return
 358  	}
 359  	json.NewEncoder(w).Encode(map[string]string{"status": "hidden"})
 360  }
 361  
 362  func (s *Server) handleChromeStatus(w http.ResponseWriter, r *http.Request) {
 363  	w.Header().Set("Content-Type", "application/json")
 364  	status := getChromeStatusOnPortFn(s.chromeControlPort())
 365  	json.NewEncoder(w).Encode(map[string]interface{}{
 366  		"running":     status.Running,
 367  		"visible":     status.Visible,
 368  		"probe_error": status.ProbeError,
 369  	})
 370  }
 371  
 372  func (s *Server) handleChromeProfile(w http.ResponseWriter, r *http.Request) {
 373  	state, err := getChromeProfileStateFn(s.configuredChromeProfile())
 374  	if err != nil {
 375  		writeError(w, http.StatusInternalServerError, err.Error())
 376  		return
 377  	}
 378  	writeJSON(w, http.StatusOK, state)
 379  }
 380  
 381  func (s *Server) handleChromeProfileUpdate(w http.ResponseWriter, r *http.Request) {
 382  	var req struct {
 383  		Mode    string `json:"mode"`
 384  		Profile string `json:"profile,omitempty"`
 385  	}
 386  	if !decodeBody(w, r, &req) {
 387  		return
 388  	}
 389  	switch req.Mode {
 390  	case "auto":
 391  		req.Profile = ""
 392  	case "explicit":
 393  		if !mcp.ValidChromeProfileName(req.Profile) {
 394  			writeError(w, http.StatusBadRequest, "invalid chrome profile name")
 395  			return
 396  		}
 397  		state, err := getChromeProfileStateFn("")
 398  		if err != nil {
 399  			writeError(w, http.StatusInternalServerError, err.Error())
 400  			return
 401  		}
 402  		found := false
 403  		for _, profile := range state.Profiles {
 404  			if profile.Name == req.Profile {
 405  				found = true
 406  				break
 407  			}
 408  		}
 409  		if !found {
 410  			writeError(w, http.StatusBadRequest, "chrome profile not found")
 411  			return
 412  		}
 413  	default:
 414  		writeError(w, http.StatusBadRequest, `mode must be "auto" or "explicit"`)
 415  		return
 416  	}
 417  
 418  	patch := map[string]interface{}{
 419  		"daemon": map[string]interface{}{
 420  			"chrome_profile": nil,
 421  		},
 422  	}
 423  	if req.Profile != "" {
 424  		patch["daemon"] = map[string]interface{}{
 425  			"chrome_profile": req.Profile,
 426  		}
 427  	}
 428  	prevProfile := s.configuredChromeProfile()
 429  	if err := s.patchGlobalConfig(patch); err != nil {
 430  		writeError(w, http.StatusInternalServerError, err.Error())
 431  		return
 432  	}
 433  	s.setConfiguredChromeProfile(req.Profile)
 434  	stopChromeFn()
 435  	if err := resetChromeProfileCloneFn(); err != nil {
 436  		rollbackPatch := map[string]interface{}{
 437  			"daemon": map[string]interface{}{
 438  				"chrome_profile": nil,
 439  			},
 440  		}
 441  		if prevProfile != "" {
 442  			rollbackPatch["daemon"] = map[string]interface{}{
 443  				"chrome_profile": prevProfile,
 444  			}
 445  		}
 446  		if rollbackErr := s.patchGlobalConfig(rollbackPatch); rollbackErr != nil {
 447  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to refresh chrome profile clone: %v (rollback failed: %v)", err, rollbackErr))
 448  			return
 449  		}
 450  		s.setConfiguredChromeProfile(prevProfile)
 451  		writeError(w, http.StatusInternalServerError, err.Error())
 452  		return
 453  	}
 454  
 455  	state, err := getChromeProfileStateFn(req.Profile)
 456  	if err != nil {
 457  		writeError(w, http.StatusInternalServerError, err.Error())
 458  		return
 459  	}
 460  	writeJSON(w, http.StatusOK, state)
 461  }
 462  
 463  func (s *Server) handleChromeProfileRefresh(w http.ResponseWriter, r *http.Request) {
 464  	stopChromeFn()
 465  	if err := resetChromeProfileCloneFn(); err != nil {
 466  		writeError(w, http.StatusInternalServerError, err.Error())
 467  		return
 468  	}
 469  	state, err := getChromeProfileStateFn(s.configuredChromeProfile())
 470  	if err != nil {
 471  		writeError(w, http.StatusInternalServerError, err.Error())
 472  		return
 473  	}
 474  	writeJSON(w, http.StatusOK, state)
 475  }
 476  
 477  func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
 478  	w.Header().Set("Content-Type", "application/json")
 479  	json.NewEncoder(w).Encode(map[string]string{"status": "shutting_down"})
 480  	if s.cancel != nil {
 481  		log.Println("daemon: shutdown requested via /shutdown")
 482  		mcp.StopCDPChrome()
 483  		go s.cancel()
 484  	}
 485  }
 486  
 487  func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) {
 488  	var req struct {
 489  		RouteKey  string `json:"route_key,omitempty"`
 490  		SessionID string `json:"session_id,omitempty"`
 491  		Agent     string `json:"agent,omitempty"`
 492  	}
 493  	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 494  		http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
 495  		return
 496  	}
 497  
 498  	key := req.RouteKey
 499  	if key == "" && req.SessionID != "" {
 500  		key = "session:" + sanitizeRouteValue(req.SessionID)
 501  	}
 502  	if key == "" && req.Agent != "" {
 503  		key = "agent:" + sanitizeRouteValue(req.Agent)
 504  	}
 505  	if key == "" {
 506  		http.Error(w, `{"error":"route_key, session_id, or agent required"}`, http.StatusBadRequest)
 507  		return
 508  	}
 509  
 510  	s.deps.SessionCache.CancelRoute(key)
 511  	w.Header().Set("Content-Type", "application/json")
 512  	json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "route": key})
 513  }
 514  
 515  func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) {
 516  	var req ApprovalResponse
 517  	if !decodeBody(w, r, &req) {
 518  		return
 519  	}
 520  	if req.RequestID == "" {
 521  		http.Error(w, `{"error":"request_id required"}`, http.StatusBadRequest)
 522  		return
 523  	}
 524  	switch req.Decision {
 525  	case DecisionAllow, DecisionDeny, DecisionAlwaysAllow:
 526  	default:
 527  		http.Error(w, `{"error":"decision must be allow, deny, or always_allow"}`, http.StatusBadRequest)
 528  		return
 529  	}
 530  	// Notify Cloud and emit event BEFORE unblocking the agent.
 531  	// This ensures ShanClaw dismisses the approval card before seeing the agent reply.
 532  	_ = s.notifyApprovalResolved(ApprovalResolvedPayload{
 533  		RequestID:  req.RequestID,
 534  		Decision:   req.Decision,
 535  		ResolvedBy: "shanclaw",
 536  	})
 537  
 538  	if s.eventBus != nil {
 539  		payload, _ := json.Marshal(map[string]string{
 540  			"request_id":  req.RequestID,
 541  			"decision":    string(req.Decision),
 542  			"resolved_by": "shanclaw",
 543  		})
 544  		s.eventBus.Emit(Event{Type: EventApprovalResolved, Payload: payload})
 545  	}
 546  
 547  	// Look up the per-request broker (SSE path) or fall back to server broker (WS path).
 548  	if b, ok := s.pendingBrokers.Load(req.RequestID); ok {
 549  		b.(*ApprovalBroker).Resolve(req.RequestID, req.Decision)
 550  	} else {
 551  		s.approvalBroker.Resolve(req.RequestID, req.Decision)
 552  	}
 553  
 554  	w.Header().Set("Content-Type", "application/json")
 555  	w.Write([]byte(`{"ok":true}`))
 556  }
 557  
 558  func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
 559  	flusher, ok := w.(http.Flusher)
 560  	if !ok {
 561  		http.Error(w, "streaming not supported", http.StatusInternalServerError)
 562  		return
 563  	}
 564  
 565  	w.Header().Set("Content-Type", "text/event-stream")
 566  	w.Header().Set("Cache-Control", "no-cache")
 567  	w.Header().Set("Connection", "keep-alive")
 568  	flusher.Flush()
 569  
 570  	// Subscribe with atomic replay if client provides a last event ID.
 571  	// Check both query param (custom clients) and Last-Event-ID header
 572  	// (standard SSE EventSource reconnection per spec).
 573  	var ch <-chan Event
 574  	lastIDStr := r.URL.Query().Get("last_event_id")
 575  	if lastIDStr == "" {
 576  		lastIDStr = r.Header.Get("Last-Event-ID")
 577  	}
 578  	if lastIDStr != "" {
 579  		if lastID, err := strconv.ParseUint(lastIDStr, 10, 64); err == nil {
 580  			var missed []Event
 581  			missed, ch = s.eventBus.SubscribeWithReplay(lastID)
 582  			for _, evt := range missed {
 583  				fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", evt.ID, evt.Type, string(evt.Payload))
 584  			}
 585  			flusher.Flush()
 586  		}
 587  	}
 588  	if ch == nil {
 589  		ch = s.eventBus.Subscribe()
 590  	}
 591  	defer s.eventBus.Unsubscribe(ch)
 592  
 593  	ticker := time.NewTicker(30 * time.Second)
 594  	defer ticker.Stop()
 595  
 596  	for {
 597  		select {
 598  		case evt := <-ch:
 599  			fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", evt.ID, evt.Type, string(evt.Payload))
 600  			flusher.Flush()
 601  		case <-ticker.C:
 602  			fmt.Fprintf(w, ": keepalive\n\n")
 603  			flusher.Flush()
 604  		case <-r.Context().Done():
 605  			return
 606  		}
 607  	}
 608  }
 609  
 610  // EventBus returns the server's EventBus for emitting events.
 611  func (s *Server) EventBus() *EventBus {
 612  	return s.eventBus
 613  }
 614  
 615  func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 616  	w.Header().Set("Content-Type", "application/json")
 617  	json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": s.version})
 618  }
 619  
 620  func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
 621  	w.Header().Set("Content-Type", "application/json")
 622  	json.NewEncoder(w).Encode(map[string]interface{}{
 623  		"is_connected": s.client.IsConnected(),
 624  		"active_agent": s.client.ActiveAgent(),
 625  		"uptime":       int(s.client.Uptime().Seconds()),
 626  		"version":      s.version,
 627  	})
 628  }
 629  
 630  // handleAgents lists available agents with optional memory status.
 631  func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) {
 632  	w.Header().Set("Content-Type", "application/json")
 633  
 634  	if s.deps == nil {
 635  		json.NewEncoder(w).Encode(map[string]interface{}{"agents": []interface{}{}})
 636  		return
 637  	}
 638  
 639  	entries, err := agents.ListAgents(s.deps.AgentsDir)
 640  	if err != nil {
 641  		http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError)
 642  		return
 643  	}
 644  
 645  	type agentInfo struct {
 646  		Name         string `json:"name"`
 647  		Builtin      bool   `json:"builtin"`
 648  		Override     bool   `json:"override"`
 649  		HasMemory    bool   `json:"has_memory"`
 650  		HasConfig    bool   `json:"has_config"`
 651  		CommandCount int    `json:"command_count"`
 652  		SkillCount   int    `json:"skill_count"`
 653  	}
 654  	result := make([]agentInfo, 0, len(entries))
 655  	for _, entry := range entries {
 656  		// Resolve effective directory for definition files
 657  		dir := filepath.Join(s.deps.AgentsDir, entry.Name)
 658  		if entry.Builtin {
 659  			dir = filepath.Join(s.deps.AgentsDir, "_builtin", entry.Name)
 660  		}
 661  		// Memory is always in top-level runtime dir
 662  		runtimeDir := filepath.Join(s.deps.AgentsDir, entry.Name)
 663  		_, memErr := os.Stat(filepath.Join(runtimeDir, "MEMORY.md"))
 664  		_, cfgErr := os.Stat(filepath.Join(dir, "config.yaml"))
 665  		cmdFiles, _ := filepath.Glob(filepath.Join(dir, "commands", "*.md"))
 666  		skillFiles, _ := filepath.Glob(filepath.Join(dir, "skills", "*", "SKILL.md"))
 667  		result = append(result, agentInfo{
 668  			Name:         entry.Name,
 669  			Builtin:      entry.Builtin,
 670  			Override:     entry.Override,
 671  			HasMemory:    memErr == nil,
 672  			HasConfig:    cfgErr == nil,
 673  			CommandCount: len(cmdFiles),
 674  			SkillCount:   len(skillFiles),
 675  		})
 676  	}
 677  	json.NewEncoder(w).Encode(map[string]interface{}{"agents": result})
 678  }
 679  
 680  // handleSessions lists sessions, optionally filtered by agent.
 681  func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
 682  	w.Header().Set("Content-Type", "application/json")
 683  
 684  	if s.deps == nil {
 685  		json.NewEncoder(w).Encode(map[string]interface{}{"sessions": []interface{}{}})
 686  		return
 687  	}
 688  
 689  	agentName := r.URL.Query().Get("agent")
 690  	if agentName != "" {
 691  		if err := agents.ValidateAgentName(agentName); err != nil {
 692  			http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
 693  			return
 694  		}
 695  	}
 696  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 697  	summaries, err := mgr.List()
 698  	if err != nil {
 699  		// If the directory doesn't exist, return empty list.
 700  		if os.IsNotExist(err) {
 701  			json.NewEncoder(w).Encode(map[string]interface{}{"sessions": []interface{}{}})
 702  			return
 703  		}
 704  		http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError)
 705  		return
 706  	}
 707  	// Filter out empty sessions (created but never used).
 708  	filtered := make([]session.SessionSummary, 0, len(summaries))
 709  	for _, s := range summaries {
 710  		if s.MsgCount > 0 {
 711  			filtered = append(filtered, s)
 712  		}
 713  	}
 714  	json.NewEncoder(w).Encode(map[string]interface{}{"sessions": filtered})
 715  }
 716  
 717  // handleGetSession 返回指定 session 的完整内容(包含消息列表)。
 718  // 前端可通过消息数组的下标作为 message_index 传给 POST /sessions/{id}/edit。
 719  func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) {
 720  	if s.deps == nil {
 721  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
 722  		return
 723  	}
 724  	id := r.PathValue("id")
 725  	if id == "" {
 726  		writeError(w, http.StatusBadRequest, "session id required")
 727  		return
 728  	}
 729  	// 防止路径穿越
 730  	if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
 731  		writeError(w, http.StatusBadRequest, "invalid session id")
 732  		return
 733  	}
 734  	agentName := r.URL.Query().Get("agent")
 735  	if agentName != "" {
 736  		if err := agents.ValidateAgentName(agentName); err != nil {
 737  			writeError(w, http.StatusBadRequest, err.Error())
 738  			return
 739  		}
 740  	}
 741  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 742  	sess, err := mgr.Load(id)
 743  	if err != nil {
 744  		if os.IsNotExist(err) {
 745  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
 746  			return
 747  		}
 748  		writeError(w, http.StatusInternalServerError, err.Error())
 749  		return
 750  	}
 751  	writeJSON(w, http.StatusOK, sess)
 752  }
 753  
 754  func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
 755  	if s.deps == nil {
 756  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
 757  		return
 758  	}
 759  	id := r.PathValue("id")
 760  	if id == "" {
 761  		writeError(w, http.StatusBadRequest, "session id required")
 762  		return
 763  	}
 764  	// Prevent path traversal — session IDs must be safe filenames.
 765  	if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
 766  		writeError(w, http.StatusBadRequest, "invalid session id")
 767  		return
 768  	}
 769  	agentName := r.URL.Query().Get("agent")
 770  	if agentName != "" {
 771  		if err := agents.ValidateAgentName(agentName); err != nil {
 772  			writeError(w, http.StatusBadRequest, err.Error())
 773  			return
 774  		}
 775  	}
 776  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 777  	if err := mgr.Delete(id); err != nil {
 778  		if os.IsNotExist(err) {
 779  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
 780  			return
 781  		}
 782  		writeError(w, http.StatusInternalServerError, err.Error())
 783  		return
 784  	}
 785  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
 786  }
 787  
 788  // handleResetSession clears a named-agent session's conversation history in
 789  // place while preserving ID/Title/CWD and other metadata.
 790  // Query: ?agent=<name> (required) — default-agent sessions should be discarded
 791  // via DELETE /sessions/{id} instead; this endpoint is only for named agents
 792  // whose routing identity must survive the wipe.
 793  // Active runs are cancelled before the history is cleared.
 794  //
 795  // Known race (matches handleEditMessage): CancelBySessionID only fires the
 796  // cancel signal and does not wait for the agent loop to exit. If the loop is
 797  // in a mid-turn checkpoint save, its Save() may land after Reset(), leaving
 798  // InProgress set or partial history re-applied. Callers should ensure no run
 799  // is active before invoking /reset; a second /reset clears any residue. A
 800  // proper barrier belongs in SessionCache and is out of scope here.
 801  func (s *Server) handleResetSession(w http.ResponseWriter, r *http.Request) {
 802  	if s.deps == nil {
 803  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
 804  		return
 805  	}
 806  	id := r.PathValue("id")
 807  	if id == "" {
 808  		writeError(w, http.StatusBadRequest, "session id required")
 809  		return
 810  	}
 811  	if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
 812  		writeError(w, http.StatusBadRequest, "invalid session id")
 813  		return
 814  	}
 815  	agentName := r.URL.Query().Get("agent")
 816  	if agentName == "" {
 817  		writeError(w, http.StatusBadRequest, "agent query parameter is required; use DELETE /sessions/{id} to discard a default-agent session")
 818  		return
 819  	}
 820  	if err := agents.ValidateAgentName(agentName); err != nil {
 821  		writeError(w, http.StatusBadRequest, err.Error())
 822  		return
 823  	}
 824  
 825  	s.deps.SessionCache.CancelBySessionID(id)
 826  
 827  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 828  	if err := mgr.Reset(id); err != nil {
 829  		if os.IsNotExist(err) {
 830  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
 831  			return
 832  		}
 833  		writeError(w, http.StatusInternalServerError, err.Error())
 834  		return
 835  	}
 836  	writeJSON(w, http.StatusOK, map[string]string{"status": "reset", "id": id})
 837  }
 838  
 839  func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
 840  	if s.deps == nil {
 841  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
 842  		return
 843  	}
 844  	id := r.PathValue("id")
 845  	if id == "" {
 846  		writeError(w, http.StatusBadRequest, "session id required")
 847  		return
 848  	}
 849  	// Prevent path traversal — session ID must be a safe filename.
 850  	if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
 851  		writeError(w, http.StatusBadRequest, "invalid session id")
 852  		return
 853  	}
 854  	var body struct {
 855  		Title string `json:"title"`
 856  	}
 857  	if !decodeBody(w, r, &body) {
 858  		return
 859  	}
 860  	title := strings.TrimSpace(body.Title)
 861  	if title == "" {
 862  		writeError(w, http.StatusBadRequest, "title cannot be empty")
 863  		return
 864  	}
 865  	agentName := r.URL.Query().Get("agent")
 866  	if agentName != "" {
 867  		if err := agents.ValidateAgentName(agentName); err != nil {
 868  			writeError(w, http.StatusBadRequest, err.Error())
 869  			return
 870  		}
 871  	}
 872  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 873  	if err := mgr.PatchTitle(id, title); err != nil {
 874  		if os.IsNotExist(err) {
 875  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
 876  			return
 877  		}
 878  		writeError(w, http.StatusInternalServerError, err.Error())
 879  		return
 880  	}
 881  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "title": title})
 882  }
 883  
 884  func (s *Server) handleSessionSearch(w http.ResponseWriter, r *http.Request) {
 885  	w.Header().Set("Content-Type", "application/json")
 886  	if s.deps == nil {
 887  		json.NewEncoder(w).Encode(map[string]interface{}{"results": []interface{}{}})
 888  		return
 889  	}
 890  
 891  	agentName := r.URL.Query().Get("agent")
 892  	if agentName != "" {
 893  		if err := agents.ValidateAgentName(agentName); err != nil {
 894  			http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
 895  			return
 896  		}
 897  	}
 898  	query := r.URL.Query().Get("q")
 899  	if query == "" {
 900  		http.Error(w, `{"error":"q parameter required"}`, http.StatusBadRequest)
 901  		return
 902  	}
 903  
 904  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
 905  	results, err := mgr.Search(query, 20)
 906  	if err != nil {
 907  		http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError)
 908  		return
 909  	}
 910  	if results == nil {
 911  		results = []session.SearchResult{}
 912  	}
 913  	json.NewEncoder(w).Encode(map[string]interface{}{"results": results})
 914  }
 915  
 916  // handleEditMessage truncates session history and re-runs the agent with new content.
 917  // Body: {"message_index": N, "new_content": "...", "content": [...], "agent": "optional"}
 918  // message_index keeps the first N messages; everything after is discarded.
 919  // content is an optional array of multimodal blocks (images, files, etc.), same format as POST /message.
 920  func (s *Server) handleEditMessage(w http.ResponseWriter, r *http.Request) {
 921  	if s.deps == nil {
 922  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
 923  		return
 924  	}
 925  	id := r.PathValue("id")
 926  	if id == "" {
 927  		writeError(w, http.StatusBadRequest, "session id required")
 928  		return
 929  	}
 930  	// 防止路径穿越
 931  	if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) {
 932  		writeError(w, http.StatusBadRequest, "invalid session id")
 933  		return
 934  	}
 935  
 936  	var body struct {
 937  		MessageIndex int                   `json:"message_index"`
 938  		NewContent   string                `json:"new_content"`
 939  		Content      []RequestContentBlock `json:"content,omitempty"`
 940  		Agent        string                `json:"agent,omitempty"`
 941  	}
 942  	if !decodeBody(w, r, &body) {
 943  		return
 944  	}
 945  	// Note: Validate() not called here — inline validation runs before truncation
 946  	// to avoid side-effects on bad input.
 947  	if strings.TrimSpace(body.NewContent) == "" && len(body.Content) == 0 {
 948  		writeError(w, http.StatusBadRequest, "new_content or content is required")
 949  		return
 950  	}
 951  	if body.Agent != "" {
 952  		if err := agents.ValidateAgentName(body.Agent); err != nil {
 953  			writeError(w, http.StatusBadRequest, err.Error())
 954  			return
 955  		}
 956  	}
 957  
 958  	// Cancel any active run using this session, regardless of route key type
 959  	// (agent:<name>, session:<id>, default:<source>:<channel>).
 960  	s.deps.SessionCache.CancelBySessionID(id)
 961  
 962  	// 截断 session 历史消息
 963  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(body.Agent))
 964  	if err := mgr.TruncateMessages(id, body.MessageIndex); err != nil {
 965  		if os.IsNotExist(err) {
 966  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
 967  			return
 968  		}
 969  		writeError(w, http.StatusBadRequest, err.Error())
 970  		return
 971  	}
 972  
 973  	// 以新内容重新触发 agent,复用现有消息发送流程
 974  	runReq := RunAgentRequest{
 975  		Text:      body.NewContent,
 976  		Content:   body.Content,
 977  		Agent:     body.Agent,
 978  		SessionID: id,
 979  		Source:    "shanclaw",
 980  	}
 981  	runReq.EnsureRouteKey()
 982  
 983  	if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
 984  		s.handleMessageSSE(w, r, runReq)
 985  		return
 986  	}
 987  
 988  	handler := &httpEventHandler{}
 989  	result, err := RunAgent(r.Context(), s.deps, runReq, handler)
 990  	if err != nil {
 991  		writeError(w, http.StatusInternalServerError, err.Error())
 992  		return
 993  	}
 994  	w.Header().Set("Content-Type", "application/json")
 995  	json.NewEncoder(w).Encode(result)
 996  }
 997  
 998  // handleSessionSummary 生成面向人类阅读的会话摘要,带缓存。
 999  // 缓存失效条件:消息数量或 UpdatedAt 变化(新消息追加或编辑 truncate)。
1000  // TODO: 对同一 session 的并发请求可能触发多次 LLM 调用,低优先级优化。
1001  func (s *Server) handleSessionSummary(w http.ResponseWriter, r *http.Request) {
1002  	if s.deps == nil {
1003  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
1004  		return
1005  	}
1006  	id := r.PathValue("id")
1007  	if id == "" {
1008  		writeError(w, http.StatusBadRequest, "session id required")
1009  		return
1010  	}
1011  	if id != filepath.Base(id) {
1012  		writeError(w, http.StatusBadRequest, "invalid session id")
1013  		return
1014  	}
1015  	agentName := r.URL.Query().Get("agent")
1016  	if agentName != "" {
1017  		if err := agents.ValidateAgentName(agentName); err != nil {
1018  			writeError(w, http.StatusBadRequest, err.Error())
1019  			return
1020  		}
1021  	}
1022  
1023  	mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName))
1024  	sess, err := mgr.Load(id)
1025  	if err != nil {
1026  		if os.IsNotExist(err) {
1027  			writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
1028  			return
1029  		}
1030  		writeError(w, http.StatusInternalServerError, err.Error())
1031  		return
1032  	}
1033  
1034  	if len(sess.Messages) == 0 {
1035  		writeError(w, http.StatusBadRequest, "session has no messages")
1036  		return
1037  	}
1038  
1039  	// 缓存 key = "消息数:UpdatedAt纳秒",任何消息变更或编辑都会使之失效
1040  	cacheKey := fmt.Sprintf("%d:%d", len(sess.Messages), sess.UpdatedAt.UnixNano())
1041  
1042  	// 缓存命中
1043  	if sess.SummaryCache != "" && sess.SummaryCacheKey == cacheKey {
1044  		writeJSON(w, http.StatusOK, map[string]any{
1045  			"summary":       sess.SummaryCache,
1046  			"cached":        true,
1047  			"message_count": len(sess.Messages),
1048  		})
1049  		return
1050  	}
1051  
1052  	// 缓存未命中:调用 LLM 生成摘要
1053  	summary, err := ctxwin.SummarizeForUser(r.Context(), s.deps.GW, sess.Messages)
1054  	if err != nil {
1055  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("summarization failed: %v", err))
1056  		return
1057  	}
1058  
1059  	// 从磁盘重新读取最新 session 后仅 patch 缓存字段,避免覆盖 agent 期间追加的新消息
1060  	if saveErr := mgr.PatchSummaryCache(id, summary, cacheKey); saveErr != nil {
1061  		log.Printf("daemon: failed to save summary cache for session %s: %v", id, saveErr)
1062  	}
1063  
1064  	writeJSON(w, http.StatusOK, map[string]any{
1065  		"summary":       summary,
1066  		"cached":        false,
1067  		"message_count": len(sess.Messages),
1068  	})
1069  }
1070  
1071  // handleMessage runs an agent turn via POST. Supports synchronous JSON and SSE streaming.
1072  func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
1073  	if s.deps == nil {
1074  		http.Error(w, `{"error":"daemon deps not configured"}`, http.StatusInternalServerError)
1075  		return
1076  	}
1077  
1078  	var req RunAgentRequest
1079  	if !decodeBody(w, r, &req) {
1080  		return
1081  	}
1082  	if req.Source == "" {
1083  		req.Source = "shanclaw"
1084  	}
1085  	// Normalize "default" → "" early so downstream guards are consistent.
1086  	if req.Agent == "default" {
1087  		req.Agent = ""
1088  	}
1089  	// Named agents always resume their single long-lived session.
1090  	// Clear new_session so clients cannot fork a named agent's context.
1091  	if req.Agent != "" {
1092  		req.NewSession = false
1093  	}
1094  	req.EnsureRouteKey()
1095  	if err := req.Validate(); err != nil {
1096  		http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
1097  		return
1098  	}
1099  
1100  	// Try injecting into an in-flight run on the same route.
1101  	if req.RouteKey != "" {
1102  		switch s.deps.SessionCache.InjectMessage(req.RouteKey, agent.InjectedMessage{Text: req.Text, CWD: req.CWD}) {
1103  		case InjectOK:
1104  			if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
1105  				w.Header().Set("Content-Type", "text/event-stream")
1106  				w.Header().Set("Cache-Control", "no-cache")
1107  				w.Header().Set("Connection", "keep-alive")
1108  				fmt.Fprintf(w, "event: injected\ndata: %s\n\n", req.RouteKey)
1109  				if f, ok := w.(http.Flusher); ok {
1110  					f.Flush()
1111  				}
1112  				return
1113  			}
1114  			w.Header().Set("Content-Type", "application/json")
1115  			json.NewEncoder(w).Encode(map[string]string{
1116  				"status": "injected",
1117  				"route":  req.RouteKey,
1118  			})
1119  			return
1120  		case InjectQueueFull:
1121  			w.Header().Set("Content-Type", "application/json")
1122  			w.WriteHeader(http.StatusTooManyRequests)
1123  			json.NewEncoder(w).Encode(map[string]string{
1124  				"status": "rejected",
1125  				"reason": "queue_full",
1126  				"route":  req.RouteKey,
1127  			})
1128  			return
1129  		case InjectBusy:
1130  			w.Header().Set("Content-Type", "application/json")
1131  			w.WriteHeader(http.StatusConflict)
1132  			json.NewEncoder(w).Encode(map[string]string{
1133  				"status": "rejected",
1134  				"reason": "active_run_not_ready",
1135  				"route":  req.RouteKey,
1136  			})
1137  			return
1138  		case InjectCWDConflict:
1139  			w.Header().Set("Content-Type", "application/json")
1140  			w.WriteHeader(http.StatusConflict)
1141  			json.NewEncoder(w).Encode(map[string]string{
1142  				"status": "rejected",
1143  				"reason": "cwd_conflict",
1144  				"route":  req.RouteKey,
1145  			})
1146  			return
1147  		case InjectNoActiveRun:
1148  			// Fall through to start a new RunAgent
1149  		}
1150  	}
1151  
1152  	if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
1153  		s.handleMessageSSE(w, r, req)
1154  		return
1155  	}
1156  
1157  	handler := &httpEventHandler{}
1158  	result, err := RunAgent(r.Context(), s.deps, req, handler)
1159  	if err != nil {
1160  		http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError)
1161  		return
1162  	}
1163  	w.Header().Set("Content-Type", "application/json")
1164  	json.NewEncoder(w).Encode(result)
1165  }
1166  
1167  // handleMessageSSE streams agent events as SSE.
1168  func (s *Server) handleMessageSSE(w http.ResponseWriter, r *http.Request, req RunAgentRequest) {
1169  	flusher, ok := w.(http.Flusher)
1170  	if !ok {
1171  		http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError)
1172  		return
1173  	}
1174  
1175  	w.Header().Set("Content-Type", "text/event-stream")
1176  	w.Header().Set("Cache-Control", "no-cache")
1177  	w.Header().Set("Connection", "keep-alive")
1178  	flusher.Flush()
1179  
1180  	// Create a per-request broker to avoid racing with concurrent SSE requests.
1181  	// Each SSE stream gets its own broker with its own sendFn and pending map.
1182  	reqBroker := NewApprovalBroker(func(areq ApprovalRequest) error {
1183  		data := mustJSON(areq)
1184  		_, err := fmt.Fprintf(w, "event: approval\ndata: %s\n\n", data)
1185  		flusher.Flush()
1186  		return err
1187  	})
1188  	// Inherit onRequest callback from the server broker for EventBus emission.
1189  	reqBroker.onRequest = s.approvalBroker.onRequest
1190  	// Register pending requestIDs so POST /approval can find this broker.
1191  	reqBroker.onRegister = func(requestID string) { s.pendingBrokers.Store(requestID, reqBroker) }
1192  	reqBroker.onDeregister = func(requestID string) { s.pendingBrokers.Delete(requestID) }
1193  
1194  	// Cancel only this request's pending approvals when the SSE stream ends.
1195  	defer reqBroker.CancelAll()
1196  
1197  	// Resolve auto_approve: per-agent overrides global
1198  	cfg, _, _ := s.deps.Snapshot()
1199  	autoApprove := cfg.Daemon.AutoApprove
1200  	if req.Agent != "" {
1201  		if a, err := agents.LoadAgent(s.deps.AgentsDir, req.Agent); err == nil && a.Config != nil && a.Config.AutoApprove != nil {
1202  			autoApprove = *a.Config.AutoApprove
1203  		}
1204  	}
1205  
1206  	handler := &sseEventHandler{w: w, flusher: flusher, broker: reqBroker, ctx: r.Context(), autoApprove: autoApprove, deps: s.deps}
1207  	result, err := RunAgent(r.Context(), s.deps, req, handler)
1208  	if err != nil {
1209  		fmt.Fprintf(w, "event: error\ndata: %s\n\n", mustJSON(map[string]string{"error": err.Error()}))
1210  		flusher.Flush()
1211  		return
1212  	}
1213  
1214  	fmt.Fprintf(w, "event: done\ndata: %s\n\n", mustJSON(result))
1215  	flusher.Flush()
1216  }
1217  
1218  // handlePermissions returns current macOS TCC permission status.
1219  func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
1220  	result := probePermissions(r.Context())
1221  	w.Header().Set("Content-Type", "application/json")
1222  	json.NewEncoder(w).Encode(result)
1223  }
1224  
1225  // handlePermissionsRequest triggers macOS permission dialogs for the requested permission.
1226  func (s *Server) handlePermissionsRequest(w http.ResponseWriter, r *http.Request) {
1227  	var req struct {
1228  		Permission string `json:"permission"` // "screen_recording", "accessibility", or "automation"
1229  	}
1230  	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
1231  		http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
1232  		return
1233  	}
1234  	switch req.Permission {
1235  	case "screen_recording", "accessibility", "automation":
1236  		// valid
1237  	default:
1238  		w.Header().Set("Content-Type", "application/json")
1239  		w.WriteHeader(http.StatusBadRequest)
1240  		json.NewEncoder(w).Encode(map[string]string{"error": "unsupported permission: " + req.Permission})
1241  		return
1242  	}
1243  	result := requestPermission(r.Context(), req.Permission)
1244  	w.Header().Set("Content-Type", "application/json")
1245  	json.NewEncoder(w).Encode(result)
1246  }
1247  
1248  // httpEventHandler is an EventHandler for synchronous HTTP responses.
1249  type httpEventHandler struct {
1250  	usage agent.UsageAccumulator
1251  }
1252  
1253  // Usage returns the cumulative usage collected during this handler's lifetime.
1254  func (h *httpEventHandler) Usage() agent.AccumulatedUsage { return h.usage.Snapshot() }
1255  
1256  func (h *httpEventHandler) OnToolCall(name string, args string) {}
1257  func (h *httpEventHandler) OnToolResult(name string, args string, result agent.ToolResult, elapsed time.Duration) {
1258  	log.Printf("http: tool %s completed (%.1fs)", name, elapsed.Seconds())
1259  }
1260  func (h *httpEventHandler) OnText(text string)            {}
1261  func (h *httpEventHandler) OnStreamDelta(delta string)    {}
1262  func (h *httpEventHandler) OnUsage(usage agent.TurnUsage) { h.usage.Add(usage) }
1263  
1264  // OnApprovalNeeded auto-approves for local HTTP API calls.
1265  // Threat model: localhost-only, unauthenticated but local-trusted.
1266  // Permission engine (hard-blocks, denied_commands) runs before this.
1267  // If daemon ever listens on non-localhost, this MUST require auth.
1268  func (h *httpEventHandler) OnCloudAgent(agentID, status, message string)           {}
1269  func (h *httpEventHandler) OnCloudProgress(completed, total int)                   {}
1270  func (h *httpEventHandler) OnCloudPlan(planType, content string, needsReview bool) {}
1271  
1272  func (h *httpEventHandler) OnApprovalNeeded(tool string, args string) bool {
1273  	return true
1274  }
1275  
1276  // sseEventHandler streams agent events as SSE to an HTTP response.
1277  type sseEventHandler struct {
1278  	w           http.ResponseWriter
1279  	flusher     http.Flusher
1280  	broker      *ApprovalBroker
1281  	ctx         context.Context
1282  	autoApprove bool
1283  	deps        *ServerDeps
1284  	usage       agent.UsageAccumulator
1285  }
1286  
1287  // Usage returns the cumulative usage collected during this handler's lifetime.
1288  func (h *sseEventHandler) Usage() agent.AccumulatedUsage { return h.usage.Snapshot() }
1289  
1290  func (h *sseEventHandler) OnToolCall(name string, args string) {
1291  	// Match bus payload: redact-first, then truncate. `audit.RedactSecrets ∘
1292  	// truncate` is wrong — a secret that straddles the byte-200 boundary
1293  	// gets chopped into a fragment before the redaction regex sees it, and
1294  	// then leaks through SSE. See redactAndTruncate + the boundary
1295  	// regression test in bus_handler_test.go.
1296  	data := mustJSON(map[string]interface{}{
1297  		"tool":   name,
1298  		"status": "running",
1299  		"args":   redactAndTruncate(args, 200),
1300  	})
1301  	fmt.Fprintf(h.w, "event: tool\ndata: %s\n\n", data)
1302  	h.flusher.Flush()
1303  }
1304  
1305  func (h *sseEventHandler) OnToolResult(name string, args string, result agent.ToolResult, elapsed time.Duration) {
1306  	// SSE is request-scoped (one tool stream per HTTP request), so session_id
1307  	// is intentionally omitted here; session correlation is handled at the client
1308  	// session boundary. `is_error` and `preview` mirror the bus payload so the
1309  	// Desktop foreground pill can render errors / a short result preview.
1310  	data := mustJSON(map[string]interface{}{
1311  		"tool":     name,
1312  		"status":   "completed",
1313  		"elapsed":  elapsed.Seconds(),
1314  		"is_error": result.IsError,
1315  		"preview":  redactAndTruncate(toolResultPreview(result), 200),
1316  	})
1317  	fmt.Fprintf(h.w, "event: tool\ndata: %s\n\n", data)
1318  	h.flusher.Flush()
1319  }
1320  
1321  func (h *sseEventHandler) OnText(text string) {}
1322  
1323  func (h *sseEventHandler) OnStreamDelta(delta string) {
1324  	data := mustJSON(map[string]string{"text": delta})
1325  	fmt.Fprintf(h.w, "event: delta\ndata: %s\n\n", data)
1326  	h.flusher.Flush()
1327  }
1328  
1329  func (h *sseEventHandler) OnUsage(usage agent.TurnUsage) {
1330  	h.usage.Add(usage)
1331  	// Also emit as SSE event so clients can render live cost meters.
1332  	data := mustJSON(map[string]interface{}{
1333  		"input_tokens":  usage.InputTokens,
1334  		"output_tokens": usage.OutputTokens,
1335  		"total_tokens":  usage.TotalTokens,
1336  		"cost_usd":      usage.CostUSD,
1337  		"llm_calls":     usage.LLMCalls,
1338  		"model":         usage.Model,
1339  	})
1340  	fmt.Fprintf(h.w, "event: usage\ndata: %s\n\n", data)
1341  	h.flusher.Flush()
1342  }
1343  
1344  func (h *sseEventHandler) OnCloudAgent(agentID, status, message string) {
1345  	data, _ := json.Marshal(map[string]interface{}{
1346  		"agent_id": agentID,
1347  		"status":   status,
1348  		"message":  message,
1349  	})
1350  	fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudAgent, data)
1351  	h.flusher.Flush()
1352  }
1353  
1354  func (h *sseEventHandler) OnCloudProgress(completed, total int) {
1355  	data, _ := json.Marshal(map[string]interface{}{
1356  		"completed": completed,
1357  		"total":     total,
1358  	})
1359  	fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudProgress, data)
1360  	h.flusher.Flush()
1361  }
1362  
1363  func (h *sseEventHandler) OnCloudPlan(planType, content string, needsReview bool) {
1364  	data, _ := json.Marshal(map[string]interface{}{
1365  		"type":         planType,
1366  		"content":      content,
1367  		"needs_review": needsReview,
1368  	})
1369  	fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudPlan, data)
1370  	h.flusher.Flush()
1371  }
1372  
1373  // OnApprovalNeeded sends an approval request over SSE and blocks until the
1374  // client responds via POST /approval or the request context is cancelled.
1375  func (h *sseEventHandler) OnApprovalNeeded(tool string, args string) bool {
1376  	if h.autoApprove {
1377  		log.Printf("sse: auto-approving %s (auto_approve=true)", tool)
1378  		return true
1379  	}
1380  	decision := h.broker.Request(h.ctx, "", "", "", tool, args)
1381  	if decision == DecisionAlwaysAllow {
1382  		if tool == "bash" {
1383  			cmd := permissions.ExtractField(args, "command")
1384  			if cmd != "" {
1385  				if err := config.AppendAllowedCommand(h.deps.ShannonDir, cmd); err != nil {
1386  					log.Printf("sse: failed to persist always-allow: %v", err)
1387  				} else {
1388  					h.deps.WriteLock()
1389  					perms := &h.deps.Config.Permissions
1390  					found := false
1391  					for _, c := range perms.AllowedCommands {
1392  						if c == cmd {
1393  							found = true
1394  							break
1395  						}
1396  					}
1397  					if !found {
1398  						perms.AllowedCommands = append(perms.AllowedCommands, cmd)
1399  					}
1400  					h.deps.WriteUnlock()
1401  					log.Printf("sse: always-allow persisted: %s", cmd)
1402  				}
1403  			}
1404  		} else {
1405  			h.broker.SetToolAutoApprove(tool)
1406  			log.Printf("sse: always-allow (session): %s", tool)
1407  		}
1408  	}
1409  	return decision == DecisionAllow || decision == DecisionAlwaysAllow
1410  }
1411  
1412  // mustJSON marshals v to JSON, returning "{}" on error.
1413  func mustJSON(v interface{}) string {
1414  	b, err := json.Marshal(v)
1415  	if err != nil {
1416  		return "{}"
1417  	}
1418  	return string(b)
1419  }
1420  
1421  // isJSONNull checks if a json.RawMessage represents a JSON null value.
1422  func isJSONNull(raw json.RawMessage) bool {
1423  	return strings.TrimSpace(string(raw)) == "null"
1424  }
1425  
1426  const (
1427  	maxBodySize   = 50 << 20 // 50 MB — accommodates base64-encoded attachments (30 MB file → ~40 MB base64)
1428  	maxUploadSize = 10 << 20
1429  )
1430  
1431  var skillSubresourceFileRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`)
1432  
1433  // decodeBody reads a JSON request body with a size limit. Returns false and
1434  // writes an error response if decoding fails.
1435  func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
1436  	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
1437  	if err := json.NewDecoder(r.Body).Decode(v); err != nil {
1438  		var maxErr *http.MaxBytesError
1439  		if errors.As(err, &maxErr) {
1440  			writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
1441  		} else {
1442  			writeError(w, http.StatusBadRequest, "invalid request body")
1443  		}
1444  		return false
1445  	}
1446  	return true
1447  }
1448  
1449  func decodeOptionalBody(w http.ResponseWriter, r *http.Request, v interface{}) (ok bool, provided bool) {
1450  	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
1451  	data, err := io.ReadAll(r.Body)
1452  	if err != nil {
1453  		var maxErr *http.MaxBytesError
1454  		if errors.As(err, &maxErr) {
1455  			writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
1456  		} else {
1457  			writeError(w, http.StatusBadRequest, "invalid request body")
1458  		}
1459  		return false, false
1460  	}
1461  	if len(strings.TrimSpace(string(data))) == 0 {
1462  		return true, false
1463  	}
1464  	if err := json.Unmarshal(data, v); err != nil {
1465  		writeError(w, http.StatusBadRequest, "invalid request body")
1466  		return false, false
1467  	}
1468  	return true, true
1469  }
1470  
1471  func (s *Server) skillSources() ([]skills.SkillSource, error) {
1472  	if s.deps == nil {
1473  		return nil, fmt.Errorf("daemon deps not configured")
1474  	}
1475  	// Only return global (installed) skills. Builtin skills (kocoro) are
1476  	// auto-installed to global by EnsureBuiltinSkills at startup.
1477  	global := skills.SkillSource{
1478  		Dir:    filepath.Join(s.deps.ShannonDir, "skills"),
1479  		Source: skills.SourceGlobal,
1480  	}
1481  	return []skills.SkillSource{global}, nil
1482  }
1483  
1484  // skillNamesFromRequest extracts the URL-safe identifier (Slug, falling
1485  // back to Name for legacy clients) for each skill entry. The returned
1486  // list is what gets persisted to _attached.yaml and what URL-based
1487  // attach routes also use — keeping body-based and URL-based attach in
1488  // sync so the same skill can be resolved by the loader regardless of
1489  // which API path wrote the manifest.
1490  func skillNamesFromRequest(entries []*skills.Skill) []string {
1491  	names := make([]string, 0, len(entries))
1492  	for _, skill := range entries {
1493  		if skill == nil {
1494  			continue
1495  		}
1496  		ident := skill.Slug
1497  		if ident == "" {
1498  			ident = skill.Name
1499  		}
1500  		if ident != "" {
1501  			names = append(names, ident)
1502  		}
1503  	}
1504  	return names
1505  }
1506  
1507  func (s *Server) validateInstalledSkills(names []string) error {
1508  	if len(names) == 0 {
1509  		return nil
1510  	}
1511  	if s.deps == nil {
1512  		return fmt.Errorf("daemon deps not configured")
1513  	}
1514  	list, err := agents.LoadGlobalSkills(s.deps.ShannonDir)
1515  	if err != nil {
1516  		return fmt.Errorf("load installed skills: %w", err)
1517  	}
1518  	// Accept either Slug (directory / marketplace identifier) or Name
1519  	// (frontmatter display label) as the identifier. Slug is the primary
1520  	// key we advise clients to use; Name is kept for backward compat.
1521  	installed := make(map[string]bool, len(list)*2)
1522  	for _, skill := range list {
1523  		installed[skill.Slug] = true
1524  		installed[skill.Name] = true
1525  	}
1526  	var missing []string
1527  	seen := make(map[string]bool, len(names))
1528  	for _, name := range names {
1529  		if seen[name] {
1530  			continue
1531  		}
1532  		seen[name] = true
1533  		if !installed[name] {
1534  			missing = append(missing, name)
1535  		}
1536  	}
1537  	if len(missing) == 0 {
1538  		return nil
1539  	}
1540  	sort.Strings(missing)
1541  	if len(missing) == 1 {
1542  		return fmt.Errorf("skill %q is not installed", missing[0])
1543  	}
1544  	return fmt.Errorf("skills not installed: %s", strings.Join(missing, ", "))
1545  }
1546  
1547  func (s *Server) resolveSkillDir(name string) (string, string, bool, error) {
1548  	if s.deps == nil {
1549  		return "", "", false, fmt.Errorf("daemon deps not configured")
1550  	}
1551  	globalDir := filepath.Join(s.deps.ShannonDir, "skills", name)
1552  	if _, err := os.Stat(filepath.Join(globalDir, "SKILL.md")); err == nil {
1553  		return globalDir, skills.SourceGlobal, false, nil
1554  	}
1555  	return "", "", false, os.ErrNotExist
1556  }
1557  
1558  func isValidSkillFileName(name string) bool {
1559  	if len(name) == 0 || len(name) > 255 {
1560  		return false
1561  	}
1562  	return filepath.Base(name) == name && skillSubresourceFileRE.MatchString(name)
1563  }
1564  
1565  func modeForSubresource(subdir string) os.FileMode {
1566  	switch subdir {
1567  	case "scripts":
1568  		return 0755
1569  	default:
1570  		return 0644
1571  	}
1572  }
1573  
1574  // configKeyAliases maps known camelCase/PascalCase JSON field names that legacy
1575  // clients may send back to their canonical snake_case YAML equivalents.
1576  var configKeyAliases = map[string]string{
1577  	"apiKey":          "api_key",
1578  	"APIKey":          "api_key",
1579  	"modelTier":       "model_tier",
1580  	"ModelTier":       "model_tier",
1581  	"autoUpdateCheck": "auto_update_check",
1582  	"AutoUpdateCheck": "auto_update_check",
1583  	"mcpServers":      "mcp_servers",
1584  	"MCPServers":      "mcp_servers",
1585  }
1586  
1587  // normalizePatchKeys rewrites known camelCase aliases to snake_case at the
1588  // top level of m only. All aliases in configKeyAliases are top-level config
1589  // keys; nested maps are intentionally not traversed to avoid false-positive
1590  // renames of unrelated fields that share an alias name.
1591  // When both an alias and its canonical key are present, the canonical wins
1592  // and the alias is discarded.
1593  func normalizePatchKeys(m map[string]interface{}) {
1594  	if m == nil {
1595  		return
1596  	}
1597  	for k := range m {
1598  		canonical, aliased := configKeyAliases[k]
1599  		if !aliased {
1600  			continue
1601  		}
1602  		if _, canonicalExists := m[canonical]; !canonicalExists {
1603  			m[canonical] = m[k]
1604  		}
1605  		delete(m, k)
1606  	}
1607  }
1608  
1609  // stripRedactedSecrets removes "***" placeholder values from the known sensitive
1610  // paths only: top-level api_key and mcp_servers.<name>.env.<var>. This prevents
1611  // a GET→PATCH round-trip from overwriting real credentials with redacted values,
1612  // without globally blocking the literal string "***" as a config value elsewhere.
1613  func stripRedactedSecrets(m map[string]interface{}) {
1614  	if m == nil {
1615  		return
1616  	}
1617  	if s, ok := m["api_key"].(string); ok && s == "***" {
1618  		delete(m, "api_key")
1619  	}
1620  	servers, ok := m["mcp_servers"].(map[string]interface{})
1621  	if !ok {
1622  		return
1623  	}
1624  	for _, srv := range servers {
1625  		srvMap, ok := srv.(map[string]interface{})
1626  		if !ok {
1627  			continue
1628  		}
1629  		env, ok := srvMap["env"].(map[string]interface{})
1630  		if !ok {
1631  			continue
1632  		}
1633  		for k, v := range env {
1634  			if s, ok := v.(string); ok && s == "***" {
1635  				delete(env, k)
1636  			}
1637  		}
1638  	}
1639  }
1640  
1641  // redactConfigSecrets removes sensitive values from a config map before
1642  // sending it over the API. Redacts api_key at top level and env vars
1643  // inside mcp_servers entries.
1644  func redactConfigSecrets(m map[string]interface{}) {
1645  	if m == nil {
1646  		return
1647  	}
1648  	if _, ok := m["api_key"]; ok {
1649  		m["api_key"] = "***"
1650  	}
1651  	servers, ok := m["mcp_servers"].(map[string]interface{})
1652  	if !ok {
1653  		return
1654  	}
1655  	for _, srv := range servers {
1656  		srvMap, ok := srv.(map[string]interface{})
1657  		if !ok {
1658  			continue
1659  		}
1660  		if env, ok := srvMap["env"].(map[string]interface{}); ok {
1661  			for k := range env {
1662  				env[k] = "***"
1663  			}
1664  		}
1665  	}
1666  }
1667  
1668  // deepMerge merges src into dst recursively (RFC 7386 JSON Merge Patch).
1669  // null values delete keys, nested maps merge, scalars replace.
1670  func deepMerge(dst, src map[string]interface{}) {
1671  	for key, srcVal := range src {
1672  		if srcVal == nil {
1673  			delete(dst, key)
1674  			continue
1675  		}
1676  		srcMap, srcIsMap := srcVal.(map[string]interface{})
1677  		if srcIsMap {
1678  			if dstVal, ok := dst[key]; ok {
1679  				if dstMap, dstIsMap := dstVal.(map[string]interface{}); dstIsMap {
1680  					deepMerge(dstMap, srcMap)
1681  					continue
1682  				}
1683  			}
1684  		}
1685  		dst[key] = srcVal
1686  	}
1687  }
1688  
1689  func pruneEmptyMaps(m map[string]interface{}) bool {
1690  	for key, val := range m {
1691  		switch v := val.(type) {
1692  		case nil:
1693  			delete(m, key)
1694  		case map[string]interface{}:
1695  			if pruneEmptyMaps(v) {
1696  				delete(m, key)
1697  			}
1698  		}
1699  	}
1700  	return len(m) == 0
1701  }
1702  
1703  func (s *Server) patchGlobalConfig(patch map[string]interface{}) error {
1704  	globalPath := filepath.Join(s.deps.ShannonDir, "config.yaml")
1705  	globalData, _ := os.ReadFile(globalPath)
1706  	var current map[string]interface{}
1707  	if len(globalData) > 0 {
1708  		if err := yaml.Unmarshal(globalData, &current); err != nil {
1709  			return fmt.Errorf("existing config is corrupt: %v", err)
1710  		}
1711  	}
1712  	if current == nil {
1713  		current = make(map[string]interface{})
1714  	}
1715  
1716  	normalizePatchKeys(patch)
1717  	stripRedactedSecrets(patch)
1718  	deepMerge(current, patch)
1719  	pruneEmptyMaps(current)
1720  
1721  	data, err := yaml.Marshal(current)
1722  	if err != nil {
1723  		return err
1724  	}
1725  	return agents.AtomicWrite(globalPath, data)
1726  }
1727  
1728  func writeJSON(w http.ResponseWriter, status int, v interface{}) {
1729  	w.Header().Set("Content-Type", "application/json")
1730  	w.WriteHeader(status)
1731  	json.NewEncoder(w).Encode(v)
1732  }
1733  
1734  func writeError(w http.ResponseWriter, status int, msg string) {
1735  	w.Header().Set("Content-Type", "application/json")
1736  	w.WriteHeader(status)
1737  	json.NewEncoder(w).Encode(map[string]string{"error": msg})
1738  }
1739  
1740  // --- Agent CRUD handlers ---
1741  
1742  func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
1743  	name := r.PathValue("name")
1744  	if err := agents.ValidateAgentName(name); err != nil {
1745  		writeError(w, http.StatusBadRequest, err.Error())
1746  		return
1747  	}
1748  	if !s.agentExists(w, name) {
1749  		return
1750  	}
1751  	a, err := agents.LoadAgent(s.deps.AgentsDir, name)
1752  	if err != nil {
1753  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load agent %s: %v", name, err))
1754  		return
1755  	}
1756  	api := a.ToAPI()
1757  
1758  	// Add builtin metadata — match ListAgents semantics:
1759  	// Builtin=true only when loaded from _builtin (no user override).
1760  	// Overridden=true when a user override exists for a builtin.
1761  	builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name)
1762  	userDir := filepath.Join(s.deps.AgentsDir, name)
1763  	_, builtinErr := os.Stat(filepath.Join(builtinDir, "AGENT.md"))
1764  	_, userErr := os.Stat(filepath.Join(userDir, "AGENT.md"))
1765  	hasBuiltin := builtinErr == nil
1766  	hasUser := userErr == nil
1767  	api.Builtin = hasBuiltin && !hasUser   // builtin-only, no user override
1768  	api.Overridden = hasBuiltin && hasUser // user override of a builtin
1769  
1770  	// Populate non-fatal trigger-conflict warnings (heartbeat ⊕ schedule).
1771  	// Best-effort — missing schedule manager or list errors yield no warnings.
1772  	if s.deps.ScheduleManager != nil {
1773  		if list, err := s.deps.ScheduleManager.List(); err == nil {
1774  			refs := make([]agents.ScheduleRef, 0, len(list))
1775  			for _, sc := range list {
1776  				refs = append(refs, agents.ScheduleRef{ID: sc.ID, Agent: sc.Agent, Enabled: sc.Enabled})
1777  			}
1778  			api.Warnings = agents.DetectTriggerConflicts(s.deps.AgentsDir, name, refs)
1779  		}
1780  	}
1781  
1782  	writeJSON(w, http.StatusOK, api)
1783  }
1784  
1785  func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) {
1786  	var req agents.AgentCreateRequest
1787  	if !decodeBody(w, r, &req) {
1788  		return
1789  	}
1790  	if err := req.Validate(); err != nil {
1791  		writeError(w, http.StatusBadRequest, err.Error())
1792  		return
1793  	}
1794  	if err := s.validateInstalledSkills(skillNamesFromRequest(req.Skills)); err != nil {
1795  		writeError(w, http.StatusBadRequest, err.Error())
1796  		return
1797  	}
1798  	// Serialize creates for the same agent name to prevent concurrent rollback races.
1799  	routeKey := "agent:" + req.Name
1800  	s.deps.SessionCache.LockRoute(routeKey)
1801  	defer s.deps.SessionCache.UnlockRoute(routeKey)
1802  
1803  	agentDir := filepath.Join(s.deps.AgentsDir, req.Name)
1804  	if _, err := os.Stat(filepath.Join(agentDir, "AGENT.md")); err == nil {
1805  		writeError(w, http.StatusConflict, fmt.Sprintf("agent %q already exists", req.Name))
1806  		return
1807  	}
1808  	// If name matches a builtin, materialize user override first so the
1809  	// subsequent writes land in the user dir and override the builtin.
1810  	if agents.IsBuiltinAgent(req.Name) {
1811  		if err := agents.MaterializeBuiltin(s.deps.AgentsDir, req.Name); err != nil {
1812  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to materialize builtin: %s", err))
1813  			return
1814  		}
1815  	}
1816  	// Write all agent files — rollback on any failure.
1817  	// Only remove files we materialized; preserve MEMORY.md and sessions/
1818  	// which are runtime state that may exist from prior builtin agent usage.
1819  	rollback := func() {
1820  		dir := filepath.Join(s.deps.AgentsDir, req.Name)
1821  		os.Remove(filepath.Join(dir, "AGENT.md"))
1822  		os.Remove(filepath.Join(dir, "config.yaml"))
1823  		os.Remove(filepath.Join(dir, "_attached.yaml"))
1824  		os.RemoveAll(filepath.Join(dir, "commands"))
1825  		os.RemoveAll(filepath.Join(dir, "skills"))
1826  	}
1827  	if err := agents.WriteAgentPrompt(s.deps.AgentsDir, req.Name, req.Prompt); err != nil {
1828  		rollback()
1829  		writeError(w, http.StatusInternalServerError, err.Error())
1830  		return
1831  	}
1832  	if req.Memory != nil {
1833  		if err := agents.WriteAgentMemory(s.deps.AgentsDir, req.Name, *req.Memory); err != nil {
1834  			rollback()
1835  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write memory: %v", err))
1836  			return
1837  		}
1838  	}
1839  	if req.Config != nil {
1840  		if err := agents.WriteAgentConfig(s.deps.AgentsDir, req.Name, req.Config); err != nil {
1841  			rollback()
1842  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write config: %v", err))
1843  			return
1844  		}
1845  	}
1846  	for name, content := range req.Commands {
1847  		if err := agents.WriteAgentCommand(s.deps.AgentsDir, req.Name, name, content); err != nil {
1848  			rollback()
1849  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write command %s: %v", name, err))
1850  			return
1851  		}
1852  	}
1853  	if len(req.Skills) > 0 {
1854  		if err := agents.SetAttachedSkills(s.deps.AgentsDir, req.Name, skillNamesFromRequest(req.Skills)); err != nil {
1855  			rollback()
1856  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write skill manifest: %v", err))
1857  			return
1858  		}
1859  	}
1860  	a, err := agents.LoadAgent(s.deps.AgentsDir, req.Name)
1861  	if err != nil {
1862  		rollback()
1863  		writeError(w, http.StatusInternalServerError, err.Error())
1864  		return
1865  	}
1866  	s.auditHTTPOp("POST", "/agents", "created agent "+req.Name)
1867  	writeJSON(w, http.StatusCreated, a.ToAPI())
1868  }
1869  
1870  func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
1871  	name := r.PathValue("name")
1872  	if err := agents.ValidateAgentName(name); err != nil {
1873  		writeError(w, http.StatusBadRequest, err.Error())
1874  		return
1875  	}
1876  	if !s.agentExists(w, name) {
1877  		return
1878  	}
1879  	agentDir := filepath.Join(s.deps.AgentsDir, name)
1880  	var req agents.AgentUpdateRequest
1881  	if !decodeBody(w, r, &req) {
1882  		return
1883  	}
1884  
1885  	// --- Pre-validate all fields before any mutations ---
1886  	if req.Prompt != nil && *req.Prompt == "" {
1887  		writeError(w, http.StatusBadRequest, "prompt cannot be empty")
1888  		return
1889  	}
1890  	var parsedMemory *string
1891  	if req.Memory != nil && !isJSONNull(req.Memory) {
1892  		var mem string
1893  		if err := json.Unmarshal(req.Memory, &mem); err != nil {
1894  			writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid memory value: %v", err))
1895  			return
1896  		}
1897  		parsedMemory = &mem
1898  	}
1899  	var parsedConfig *agents.AgentConfigAPI
1900  	if req.Config != nil && !isJSONNull(req.Config) {
1901  		var cfg agents.AgentConfigAPI
1902  		if err := json.Unmarshal(req.Config, &cfg); err != nil {
1903  			writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid config value: %v", err))
1904  			return
1905  		}
1906  		if cfg.Tools != nil {
1907  			if err := agents.ValidateToolsFilter(cfg.Tools); err != nil {
1908  				writeError(w, http.StatusBadRequest, err.Error())
1909  				return
1910  			}
1911  		}
1912  		parsedConfig = &cfg
1913  	}
1914  	for cmdName := range req.Commands {
1915  		if err := agents.ValidateCommandName(cmdName); err != nil {
1916  			writeError(w, http.StatusBadRequest, err.Error())
1917  			return
1918  		}
1919  	}
1920  	for _, skill := range req.Skills {
1921  		if skill == nil {
1922  			writeError(w, http.StatusBadRequest, "skill entry cannot be null")
1923  			return
1924  		}
1925  		// Validate the URL-safe identifier (Slug) rather than the
1926  		// display Name. Legacy clients that only send Name fall through
1927  		// to Name validation for backward compatibility.
1928  		ident := skill.Slug
1929  		if ident == "" {
1930  			ident = skill.Name
1931  		}
1932  		if err := skills.ValidateSkillName(ident); err != nil {
1933  			writeError(w, http.StatusBadRequest, err.Error())
1934  			return
1935  		}
1936  	}
1937  	if err := s.validateInstalledSkills(skillNamesFromRequest(req.Skills)); err != nil {
1938  		writeError(w, http.StatusBadRequest, err.Error())
1939  		return
1940  	}
1941  
1942  	// Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input.
1943  	if !s.materializeIfBuiltin(w, name) {
1944  		return
1945  	}
1946  
1947  	// --- Apply mutations (all inputs validated) ---
1948  	if req.Prompt != nil {
1949  		if err := agents.WriteAgentPrompt(s.deps.AgentsDir, name, *req.Prompt); err != nil {
1950  			writeError(w, http.StatusInternalServerError, err.Error())
1951  			return
1952  		}
1953  	}
1954  	if req.Memory != nil {
1955  		if isJSONNull(req.Memory) {
1956  			if err := os.Remove(filepath.Join(agentDir, "MEMORY.md")); err != nil && !os.IsNotExist(err) {
1957  				writeError(w, http.StatusInternalServerError, fmt.Sprintf("delete memory: %v", err))
1958  				return
1959  			}
1960  		} else {
1961  			if err := agents.WriteAgentMemory(s.deps.AgentsDir, name, *parsedMemory); err != nil {
1962  				writeError(w, http.StatusInternalServerError, err.Error())
1963  				return
1964  			}
1965  		}
1966  	}
1967  	if req.Config != nil {
1968  		if isJSONNull(req.Config) {
1969  			if err := os.Remove(filepath.Join(agentDir, "config.yaml")); err != nil && !os.IsNotExist(err) {
1970  				writeError(w, http.StatusInternalServerError, fmt.Sprintf("delete config: %v", err))
1971  				return
1972  			}
1973  		} else {
1974  			if err := agents.WriteAgentConfig(s.deps.AgentsDir, name, parsedConfig); err != nil {
1975  				writeError(w, http.StatusInternalServerError, err.Error())
1976  				return
1977  			}
1978  		}
1979  	}
1980  	for cmdName, content := range req.Commands {
1981  		if err := agents.WriteAgentCommand(s.deps.AgentsDir, name, cmdName, content); err != nil {
1982  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write command %s: %v", cmdName, err))
1983  			return
1984  		}
1985  	}
1986  	if req.Skills != nil {
1987  		// Write attached skills manifest — agent loader resolves content from global/bundled.
1988  		if err := agents.SetAttachedSkills(s.deps.AgentsDir, name, skillNamesFromRequest(req.Skills)); err != nil {
1989  			writeError(w, http.StatusInternalServerError, fmt.Sprintf("write skill manifest: %v", err))
1990  			return
1991  		}
1992  		// Clean up any legacy agent-scoped SKILL.md files
1993  		agentSkillsDir := filepath.Join(s.deps.AgentsDir, name, "skills")
1994  		_ = os.RemoveAll(agentSkillsDir)
1995  	}
1996  	a, err := agents.LoadAgent(s.deps.AgentsDir, name)
1997  	if err != nil {
1998  		writeError(w, http.StatusInternalServerError, err.Error())
1999  		return
2000  	}
2001  	writeJSON(w, http.StatusOK, a.ToAPI())
2002  }
2003  
2004  func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) {
2005  	if requireConfirm(r.URL.Query().Get("confirm")) {
2006  		writeJSON(w, http.StatusBadRequest, map[string]string{
2007  			"error":   "confirmation_required",
2008  			"message": "This will permanently delete the agent definition. Add ?confirm=true to proceed.",
2009  		})
2010  		return
2011  	}
2012  	name := r.PathValue("name")
2013  	if err := agents.ValidateAgentName(name); err != nil {
2014  		writeError(w, http.StatusBadRequest, err.Error())
2015  		return
2016  	}
2017  	if !s.agentExists(w, name) {
2018  		return
2019  	}
2020  	// Cannot delete a builtin-only agent (no user override)
2021  	userDir := filepath.Join(s.deps.AgentsDir, name)
2022  	builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name)
2023  	_, userErr := os.Stat(filepath.Join(userDir, "AGENT.md"))
2024  	_, builtinErr := os.Stat(filepath.Join(builtinDir, "AGENT.md"))
2025  	if userErr != nil && builtinErr == nil {
2026  		writeError(w, http.StatusForbidden, "cannot delete system-managed builtin agent")
2027  		return
2028  	}
2029  	// Evict handles its own per-route locking — do NOT wrap with Lock/Unlock
2030  	// (that would self-deadlock since Evict calls evictRoute which acquires entry.mu).
2031  	s.deps.SessionCache.Evict(name)
2032  	// Remove only definition files — preserve runtime state (MEMORY.md, sessions/)
2033  	// so the builtin can resurface with existing history intact.
2034  	agentDir := filepath.Join(s.deps.AgentsDir, name)
2035  	var errs []string
2036  	for _, f := range []string{"AGENT.md", "config.yaml", "_attached.yaml"} {
2037  		p := filepath.Join(agentDir, f)
2038  		if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
2039  			errs = append(errs, err.Error())
2040  		}
2041  	}
2042  	for _, d := range []string{"commands", "skills"} {
2043  		p := filepath.Join(agentDir, d)
2044  		if err := os.RemoveAll(p); err != nil {
2045  			errs = append(errs, err.Error())
2046  		}
2047  	}
2048  	if len(errs) > 0 {
2049  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("partial delete: %s", strings.Join(errs, "; ")))
2050  		return
2051  	}
2052  	// Clean up empty dir if no runtime state remains
2053  	if entries, err := os.ReadDir(agentDir); err == nil && len(entries) == 0 {
2054  		os.Remove(agentDir)
2055  	}
2056  	s.auditHTTPOp("DELETE", "/agents/"+name, "deleted agent")
2057  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2058  }
2059  
2060  // --- Agent sub-resource handlers ---
2061  
2062  // agentExists checks that the agent directory has AGENT.md. Returns false
2063  // and writes a 404 error if the agent does not exist.
2064  func (s *Server) agentExists(w http.ResponseWriter, name string) bool {
2065  	agentDir := filepath.Join(s.deps.AgentsDir, name)
2066  	if _, err := os.Stat(filepath.Join(agentDir, "AGENT.md")); os.IsNotExist(err) {
2067  		// Also check _builtin fallback
2068  		builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name)
2069  		if _, err := os.Stat(filepath.Join(builtinDir, "AGENT.md")); os.IsNotExist(err) {
2070  			writeError(w, http.StatusNotFound, fmt.Sprintf("agent %q not found", name))
2071  			return false
2072  		}
2073  	}
2074  	return true
2075  }
2076  
2077  // materializeIfBuiltin checks if the agent exists only as a builtin (no user
2078  // override) and materializes it to the user dir so writes can proceed. Returns
2079  // true if the caller should continue, false if an error was already written.
2080  func (s *Server) materializeIfBuiltin(w http.ResponseWriter, name string) bool {
2081  	userDir := filepath.Join(s.deps.AgentsDir, name)
2082  	if _, err := os.Stat(filepath.Join(userDir, "AGENT.md")); err != nil {
2083  		if agents.IsBuiltinAgent(name) {
2084  			if err := agents.MaterializeBuiltin(s.deps.AgentsDir, name); err != nil {
2085  				writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to materialize builtin: %s", err))
2086  				return false
2087  			}
2088  		}
2089  	}
2090  	return true
2091  }
2092  
2093  func (s *Server) handlePutAgentConfig(w http.ResponseWriter, r *http.Request) {
2094  	name := r.PathValue("name")
2095  	if err := agents.ValidateAgentName(name); err != nil {
2096  		writeError(w, http.StatusBadRequest, err.Error())
2097  		return
2098  	}
2099  	if !s.agentExists(w, name) {
2100  		return
2101  	}
2102  	var cfg agents.AgentConfigAPI
2103  	if !decodeBody(w, r, &cfg) {
2104  		return
2105  	}
2106  	if cfg.Tools != nil {
2107  		if err := agents.ValidateToolsFilter(cfg.Tools); err != nil {
2108  			writeError(w, http.StatusBadRequest, err.Error())
2109  			return
2110  		}
2111  	}
2112  	// Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input.
2113  	if !s.materializeIfBuiltin(w, name) {
2114  		return
2115  	}
2116  	if err := agents.WriteAgentConfig(s.deps.AgentsDir, name, &cfg); err != nil {
2117  		writeError(w, http.StatusInternalServerError, err.Error())
2118  		return
2119  	}
2120  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
2121  }
2122  
2123  func (s *Server) handleDeleteAgentConfig(w http.ResponseWriter, r *http.Request) {
2124  	name := r.PathValue("name")
2125  	if err := agents.ValidateAgentName(name); err != nil {
2126  		writeError(w, http.StatusBadRequest, err.Error())
2127  		return
2128  	}
2129  	if !s.agentExists(w, name) {
2130  		return
2131  	}
2132  	if !s.materializeIfBuiltin(w, name) {
2133  		return
2134  	}
2135  	path := filepath.Join(s.deps.AgentsDir, name, "config.yaml")
2136  	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
2137  		writeError(w, http.StatusInternalServerError, err.Error())
2138  		return
2139  	}
2140  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2141  }
2142  
2143  func (s *Server) handlePutCommand(w http.ResponseWriter, r *http.Request) {
2144  	agentName := r.PathValue("name")
2145  	cmdName := r.PathValue("cmd")
2146  	if err := agents.ValidateAgentName(agentName); err != nil {
2147  		writeError(w, http.StatusBadRequest, err.Error())
2148  		return
2149  	}
2150  	if !s.agentExists(w, agentName) {
2151  		return
2152  	}
2153  	if err := agents.ValidateCommandName(cmdName); err != nil {
2154  		writeError(w, http.StatusBadRequest, err.Error())
2155  		return
2156  	}
2157  	var body struct {
2158  		Content string `json:"content"`
2159  	}
2160  	if !decodeBody(w, r, &body) {
2161  		return
2162  	}
2163  	if body.Content == "" {
2164  		writeError(w, http.StatusBadRequest, "content is required")
2165  		return
2166  	}
2167  	// Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input.
2168  	if !s.materializeIfBuiltin(w, agentName) {
2169  		return
2170  	}
2171  	if err := agents.WriteAgentCommand(s.deps.AgentsDir, agentName, cmdName, body.Content); err != nil {
2172  		writeError(w, http.StatusInternalServerError, err.Error())
2173  		return
2174  	}
2175  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
2176  }
2177  
2178  func (s *Server) handleDeleteCommand(w http.ResponseWriter, r *http.Request) {
2179  	agentName := r.PathValue("name")
2180  	cmdName := r.PathValue("cmd")
2181  	if err := agents.ValidateAgentName(agentName); err != nil {
2182  		writeError(w, http.StatusBadRequest, err.Error())
2183  		return
2184  	}
2185  	if !s.agentExists(w, agentName) {
2186  		return
2187  	}
2188  	if !s.materializeIfBuiltin(w, agentName) {
2189  		return
2190  	}
2191  	if err := agents.ValidateCommandName(cmdName); err != nil {
2192  		writeError(w, http.StatusBadRequest, err.Error())
2193  		return
2194  	}
2195  	if err := agents.DeleteAgentCommand(s.deps.AgentsDir, agentName, cmdName); err != nil && !os.IsNotExist(err) {
2196  		writeError(w, http.StatusInternalServerError, err.Error())
2197  		return
2198  	}
2199  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2200  }
2201  
2202  func (s *Server) handlePutSkill(w http.ResponseWriter, r *http.Request) {
2203  	agentName := r.PathValue("name")
2204  	skillName := r.PathValue("skill")
2205  	if err := agents.ValidateAgentName(agentName); err != nil {
2206  		writeError(w, http.StatusBadRequest, err.Error())
2207  		return
2208  	}
2209  	if !s.agentExists(w, agentName) {
2210  		return
2211  	}
2212  	if err := skills.ValidateSkillName(skillName); err != nil {
2213  		writeError(w, http.StatusBadRequest, err.Error())
2214  		return
2215  	}
2216  	var body struct {
2217  		Name string `json:"name"`
2218  	}
2219  	if ok, provided := decodeOptionalBody(w, r, &body); !ok {
2220  		return
2221  	} else if provided && body.Name != "" && body.Name != skillName {
2222  		writeError(w, http.StatusBadRequest, "skill name in body must match URL")
2223  		return
2224  	}
2225  	if err := s.validateInstalledSkills([]string{skillName}); err != nil {
2226  		writeError(w, http.StatusBadRequest, err.Error())
2227  		return
2228  	}
2229  	// Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input.
2230  	if !s.materializeIfBuiltin(w, agentName) {
2231  		return
2232  	}
2233  	if err := agents.AttachSkill(s.deps.AgentsDir, agentName, skillName); err != nil {
2234  		writeError(w, http.StatusInternalServerError, err.Error())
2235  		return
2236  	}
2237  	if err := agents.DeleteAgentSkill(s.deps.AgentsDir, agentName, skillName); err != nil && !os.IsNotExist(err) {
2238  		writeError(w, http.StatusInternalServerError, err.Error())
2239  		return
2240  	}
2241  	writeJSON(w, http.StatusOK, map[string]string{"status": "attached"})
2242  }
2243  
2244  func (s *Server) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
2245  	agentName := r.PathValue("name")
2246  	skillName := r.PathValue("skill")
2247  	if err := agents.ValidateAgentName(agentName); err != nil {
2248  		writeError(w, http.StatusBadRequest, err.Error())
2249  		return
2250  	}
2251  	if !s.agentExists(w, agentName) {
2252  		return
2253  	}
2254  	if !s.materializeIfBuiltin(w, agentName) {
2255  		return
2256  	}
2257  	if err := skills.ValidateSkillName(skillName); err != nil {
2258  		writeError(w, http.StatusBadRequest, err.Error())
2259  		return
2260  	}
2261  	if err := agents.DetachSkill(s.deps.AgentsDir, agentName, skillName); err != nil {
2262  		writeError(w, http.StatusInternalServerError, err.Error())
2263  		return
2264  	}
2265  	if err := agents.DeleteAgentSkill(s.deps.AgentsDir, agentName, skillName); err != nil && !os.IsNotExist(err) {
2266  		writeError(w, http.StatusInternalServerError, err.Error())
2267  		return
2268  	}
2269  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2270  }
2271  
2272  // --- Marketplace handlers ---
2273  
2274  func (s *Server) handleMarketplaceList(w http.ResponseWriter, r *http.Request) {
2275  	if !s.requireDeps(w) {
2276  		return
2277  	}
2278  	idx, err := s.marketplace.Load(r.Context())
2279  	if err != nil {
2280  		writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err))
2281  		return
2282  	}
2283  
2284  	q := r.URL.Query()
2285  	page := parseIntParam(q.Get("page"), 1)
2286  	size := parseIntParam(q.Get("size"), 20)
2287  	sortKey := q.Get("sort")
2288  	if sortKey == "" {
2289  		sortKey = "downloads"
2290  	}
2291  	search := q.Get("q")
2292  
2293  	entries, total := skills.FilterSortPaginate(idx.Skills, search, sortKey, page, size)
2294  
2295  	// Mark `installed` flag for entries already on disk.
2296  	installed := installedSkillSet(s.deps.ShannonDir)
2297  	type listItem struct {
2298  		skills.MarketplaceEntry
2299  		Installed bool `json:"installed"`
2300  	}
2301  	items := make([]listItem, 0, len(entries))
2302  	for _, e := range entries {
2303  		items = append(items, listItem{MarketplaceEntry: e, Installed: installed[e.Slug]})
2304  	}
2305  
2306  	if s.marketplace.IsStale() {
2307  		w.Header().Set("X-Cache-Stale", "true")
2308  	}
2309  	writeJSON(w, http.StatusOK, map[string]interface{}{
2310  		"total":  total,
2311  		"page":   page,
2312  		"size":   size,
2313  		"skills": items,
2314  	})
2315  }
2316  
2317  func (s *Server) handleSkillUsage(w http.ResponseWriter, r *http.Request) {
2318  	if !s.requireDeps(w) {
2319  		return
2320  	}
2321  	name := r.PathValue("name")
2322  	if err := skills.ValidateSkillName(name); err != nil {
2323  		writeError(w, http.StatusBadRequest, err.Error())
2324  		return
2325  	}
2326  	used, err := agents.AgentsAttachingSkill(s.deps.AgentsDir, name)
2327  	if err != nil {
2328  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("read attached skills: %v", err))
2329  		return
2330  	}
2331  	writeJSON(w, http.StatusOK, map[string]interface{}{
2332  		"skill":  name,
2333  		"agents": used,
2334  	})
2335  }
2336  
2337  func (s *Server) handleMarketplaceInstall(w http.ResponseWriter, r *http.Request) {
2338  	if !s.requireDeps(w) {
2339  		return
2340  	}
2341  	slug := r.PathValue("slug")
2342  	if err := skills.ValidateSkillName(slug); err != nil {
2343  		writeError(w, http.StatusBadRequest, err.Error())
2344  		return
2345  	}
2346  
2347  	idx, err := s.marketplace.Load(r.Context())
2348  	if err != nil {
2349  		writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err))
2350  		return
2351  	}
2352  	var entry *skills.MarketplaceEntry
2353  	for i := range idx.Skills {
2354  		if idx.Skills[i].Slug == slug {
2355  			entry = &idx.Skills[i]
2356  			break
2357  		}
2358  	}
2359  	if entry == nil {
2360  		writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in marketplace", slug))
2361  		return
2362  	}
2363  
2364  	err = skills.InstallFromMarketplace(r.Context(), s.deps.ShannonDir, *entry, s.slugLocks)
2365  	switch {
2366  	case err == nil:
2367  		// Load the freshly installed skill so the response body reflects
2368  		// on-disk truth (frontmatter name, description, source) rather
2369  		// than synthesized data from the registry. Mirrors the pattern
2370  		// used by handleInstallSkill for Anthropic-repo installs.
2371  		sources, _ := s.skillSources()
2372  		list, _ := skills.LoadSkills(sources...)
2373  		for _, skill := range list {
2374  			if skill.Slug == entry.Slug {
2375  				writeJSON(w, http.StatusCreated, skill.ToMeta())
2376  				return
2377  			}
2378  		}
2379  		// Fallback: install succeeded but the skill did not show up in
2380  		// LoadSkills. This shouldn't happen because InstallFromMarketplace
2381  		// guarantees a valid SKILL.md on success, but we return a stable
2382  		// 201 with minimal info rather than misleading the client. Slug
2383  		// is the primary identifier clients use for subsequent CRUD, so
2384  		// populate it explicitly instead of leaving it empty.
2385  		fallbackName := entry.Name
2386  		if fallbackName == "" {
2387  			fallbackName = entry.Slug
2388  		}
2389  		writeJSON(w, http.StatusCreated, skills.SkillMeta{
2390  			Name:        fallbackName,
2391  			Slug:        entry.Slug,
2392  			Description: entry.Description,
2393  			Source:      "global",
2394  		})
2395  	case errors.Is(err, skills.ErrMaliciousSkill):
2396  		writeError(w, http.StatusForbidden, err.Error())
2397  	case errors.Is(err, skills.ErrSkillAlreadyInstalled):
2398  		writeError(w, http.StatusConflict, err.Error())
2399  	case errors.Is(err, skills.ErrInvalidSkillPayload):
2400  		writeError(w, http.StatusUnprocessableEntity, err.Error())
2401  	case errors.Is(err, skills.ErrMarketplaceUpstreamFailure):
2402  		writeError(w, http.StatusBadGateway, fmt.Sprintf("install failed: %v", err))
2403  	default:
2404  		// Local disk/staging failures → 500, per spec error matrix.
2405  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("install failed: %v", err))
2406  	}
2407  }
2408  
2409  func (s *Server) handleMarketplaceDetail(w http.ResponseWriter, r *http.Request) {
2410  	if !s.requireDeps(w) {
2411  		return
2412  	}
2413  	slug := r.PathValue("slug")
2414  	if err := skills.ValidateSkillName(slug); err != nil {
2415  		writeError(w, http.StatusBadRequest, err.Error())
2416  		return
2417  	}
2418  	idx, err := s.marketplace.Load(r.Context())
2419  	if err != nil {
2420  		writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err))
2421  		return
2422  	}
2423  	var entry *skills.MarketplaceEntry
2424  	for i := range idx.Skills {
2425  		if idx.Skills[i].Slug == slug {
2426  			entry = &idx.Skills[i]
2427  			break
2428  		}
2429  	}
2430  	if entry == nil {
2431  		writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in marketplace", slug))
2432  		return
2433  	}
2434  
2435  	// Consistent with list + install: malicious entries are hidden.
2436  	if entry.IsMalicious() {
2437  		writeError(w, http.StatusForbidden, "skill blocked by security scan")
2438  		return
2439  	}
2440  
2441  	// Response wraps the registry entry plus live state. Preview holds the
2442  	// installed SKILL.md body when present — empty string otherwise, so the
2443  	// field is always part of the schema. NO omitempty so Desktop clients
2444  	// can rely on the field's existence regardless of install state.
2445  	type detailResponse struct {
2446  		skills.MarketplaceEntry
2447  		Installed bool   `json:"installed"`
2448  		Preview   string `json:"preview"`
2449  	}
2450  
2451  	resp := detailResponse{MarketplaceEntry: *entry}
2452  	skillDir := filepath.Join(s.deps.ShannonDir, "skills", slug)
2453  	skillFile := filepath.Join(skillDir, "SKILL.md")
2454  	if body, err := os.ReadFile(skillFile); err == nil {
2455  		resp.Installed = true
2456  		resp.Preview = string(body)
2457  	}
2458  
2459  	writeJSON(w, http.StatusOK, resp)
2460  }
2461  
2462  // parseIntParam parses a positive int query parameter, falling back to def
2463  // on empty or invalid input. Shared by marketplace handlers.
2464  func parseIntParam(raw string, def int) int {
2465  	if raw == "" {
2466  		return def
2467  	}
2468  	n, err := strconv.Atoi(raw)
2469  	if err != nil || n < 1 {
2470  		return def
2471  	}
2472  	return n
2473  }
2474  
2475  // installedSkillSet returns the set of skill slugs present in
2476  // ~/.shannon/skills/. Missing directory → empty set, no error.
2477  func installedSkillSet(shannonDir string) map[string]bool {
2478  	out := make(map[string]bool)
2479  	entries, err := os.ReadDir(filepath.Join(shannonDir, "skills"))
2480  	if err != nil {
2481  		return out
2482  	}
2483  	for _, e := range entries {
2484  		if !e.IsDir() {
2485  			continue
2486  		}
2487  		if _, err := os.Stat(filepath.Join(shannonDir, "skills", e.Name(), "SKILL.md")); err == nil {
2488  			out[e.Name()] = true
2489  		}
2490  	}
2491  	return out
2492  }
2493  
2494  // --- Global skills handlers ---
2495  
2496  func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) {
2497  	sources, err := s.skillSources()
2498  	if err != nil {
2499  		writeError(w, http.StatusInternalServerError, err.Error())
2500  		return
2501  	}
2502  
2503  	list, err := skills.LoadSkills(sources...)
2504  	if err != nil {
2505  		writeError(w, http.StatusInternalServerError, err.Error())
2506  		return
2507  	}
2508  
2509  	// hidden: true is display-only — the skill is still loaded and invokable
2510  	// via use_skill. Admin/management UIs can pass ?include_hidden=true.
2511  	includeHidden := r.URL.Query().Get("include_hidden") == "true"
2512  	metas := make([]skills.SkillMeta, 0, len(list))
2513  	for _, skill := range list {
2514  		if skill.Hidden && !includeHidden {
2515  			continue
2516  		}
2517  		meta := skill.ToMeta()
2518  		meta.RequiredSecrets = skill.RequiredSecrets()
2519  		meta.ConfiguredSecrets = s.secretsStore.ConfiguredKeys(skill.Slug)
2520  		metas = append(metas, meta)
2521  	}
2522  	writeJSON(w, http.StatusOK, map[string]interface{}{"skills": metas})
2523  }
2524  
2525  func (s *Server) handleListDownloadableSkills(w http.ResponseWriter, r *http.Request) {
2526  	globalDir := filepath.Join(s.deps.ShannonDir, "skills")
2527  	result := make([]skills.DownloadableSkill, 0, len(skills.DownloadableSkills))
2528  	for _, ds := range skills.DownloadableSkills {
2529  		installed := false
2530  		if _, err := os.Stat(filepath.Join(globalDir, ds.Name, "SKILL.md")); err == nil {
2531  			installed = true
2532  		}
2533  		result = append(result, skills.DownloadableSkill{
2534  			Name:        ds.Name,
2535  			Description: ds.Description,
2536  			Installed:   installed,
2537  		})
2538  	}
2539  	writeJSON(w, http.StatusOK, map[string]interface{}{"skills": result})
2540  }
2541  
2542  func (s *Server) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
2543  	name := r.PathValue("name")
2544  	if !skills.IsDownloadable(name) {
2545  		writeError(w, http.StatusBadRequest, fmt.Sprintf("skill %q is not available for download", name))
2546  		return
2547  	}
2548  
2549  	if err := skills.InstallSkillFromRepo(s.deps.ShannonDir, name); err != nil {
2550  		if strings.Contains(err.Error(), "already installed") {
2551  			writeError(w, http.StatusConflict, err.Error())
2552  			return
2553  		}
2554  		writeError(w, http.StatusInternalServerError, err.Error())
2555  		return
2556  	}
2557  
2558  	// Load the installed skill to return its metadata
2559  	sources, _ := s.skillSources()
2560  	list, _ := skills.LoadSkills(sources...)
2561  	for _, skill := range list {
2562  		if skill.Slug == name {
2563  			writeJSON(w, http.StatusCreated, skill.ToMeta())
2564  			return
2565  		}
2566  	}
2567  	writeJSON(w, http.StatusCreated, map[string]string{"status": "installed", "name": name})
2568  }
2569  
2570  func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) {
2571  	// Intentionally does NOT filter by skill.Hidden — single-skill lookup is
2572  	// for callers that already know the slug (admin UIs, kocoro secrets
2573  	// management). Hidden is a browse-list display filter, not an access
2574  	// control. Do not add a hidden check here without revisiting handleListSkills.
2575  	name := r.PathValue("name")
2576  	if err := skills.ValidateSkillName(name); err != nil {
2577  		writeError(w, http.StatusBadRequest, err.Error())
2578  		return
2579  	}
2580  
2581  	sources, err := s.skillSources()
2582  	if err != nil {
2583  		writeError(w, http.StatusInternalServerError, err.Error())
2584  		return
2585  	}
2586  	list, err := skills.LoadSkills(sources...)
2587  	if err != nil {
2588  		writeError(w, http.StatusInternalServerError, err.Error())
2589  		return
2590  	}
2591  	for _, skill := range list {
2592  		if skill.Slug == name {
2593  			detail := skills.SkillDetail{
2594  				Name:               skill.Name,
2595  				Slug:               skill.Slug,
2596  				Description:        skill.Description,
2597  				Prompt:             skill.Prompt,
2598  				Source:             skill.Source,
2599  				InstallSource:      skill.InstallSource,
2600  				MarketplaceSlug:    skill.MarketplaceSlug,
2601  				License:            skill.License,
2602  				Compatibility:      skill.Compatibility,
2603  				Metadata:           skill.Metadata,
2604  				StickyInstructions: skill.StickyInstructions,
2605  				Hidden:             skill.Hidden,
2606  				StickySnippet:      skill.StickySnippetOverride,
2607  			}
2608  			if len(skill.AllowedTools) > 0 {
2609  				detail.AllowedTools = skill.AllowedTools
2610  			}
2611  			detail.RequiredSecrets = skill.RequiredSecrets()
2612  			detail.ConfiguredSecrets = s.secretsStore.ConfiguredKeys(skill.Slug)
2613  			writeJSON(w, http.StatusOK, detail)
2614  			return
2615  		}
2616  	}
2617  	writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name))
2618  }
2619  
2620  func (s *Server) handlePutGlobalSkill(w http.ResponseWriter, r *http.Request) {
2621  	name := r.PathValue("name")
2622  	if err := skills.ValidateSkillName(name); err != nil {
2623  		writeError(w, http.StatusBadRequest, err.Error())
2624  		return
2625  	}
2626  	var req struct {
2627  		Description        string `json:"description"`
2628  		Prompt             string `json:"prompt"`
2629  		License            string `json:"license"`
2630  		StickyInstructions *bool  `json:"sticky_instructions,omitempty"`
2631  		StickySnippet      *string `json:"sticky_snippet,omitempty"`
2632  	}
2633  	if !decodeBody(w, r, &req) {
2634  		return
2635  	}
2636  	if req.Description == "" {
2637  		writeError(w, http.StatusBadRequest, "description is required")
2638  		return
2639  	}
2640  	if req.Prompt == "" {
2641  		writeError(w, http.StatusBadRequest, "prompt is required")
2642  		return
2643  	}
2644  	if s.deps == nil {
2645  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
2646  		return
2647  	}
2648  	// Load the existing skill so we can preserve fields the PUT body doesn't
2649  	// carry (AllowedTools, Metadata, Compatibility). If the skill directory
2650  	// already exists but load fails, refuse the write rather than silently
2651  	// clobber those fields with zero values — kocoro's `allowed-tools:
2652  	// http file_read` is security-critical and must not be dropped on a
2653  	// transient FS error.
2654  	var skillToWrite skills.Skill
2655  	skillExistsOnDisk := false
2656  	if s.deps != nil && s.deps.ShannonDir != "" {
2657  		if _, statErr := os.Stat(filepath.Join(s.deps.ShannonDir, "skills", name, "SKILL.md")); statErr == nil {
2658  			skillExistsOnDisk = true
2659  		}
2660  	}
2661  	sources, err := s.skillSources()
2662  	if err != nil {
2663  		if skillExistsOnDisk {
2664  			writeError(w, http.StatusServiceUnavailable,
2665  				fmt.Sprintf("cannot resolve skill sources to preserve existing fields: %v", err))
2666  			return
2667  		}
2668  	} else {
2669  		list, loadErr := skills.LoadSkills(sources...)
2670  		if loadErr != nil && skillExistsOnDisk {
2671  			writeError(w, http.StatusServiceUnavailable,
2672  				fmt.Sprintf("cannot load existing skill %q (refusing to clobber AllowedTools/Metadata): %v", name, loadErr))
2673  			return
2674  		}
2675  		// URL param is the slug (directory identifier), not the
2676  		// frontmatter display label. Match by Slug so a skill whose
2677  		// Name differs from its Slug (e.g. "Docker" / "docker") is
2678  		// found and its AllowedTools/Metadata are preserved.
2679  		for _, existing := range list {
2680  			if existing.Slug == name {
2681  				skillToWrite = *existing
2682  				break
2683  			}
2684  		}
2685  	}
2686  	skillToWrite.Slug = name
2687  	// Preserve the existing Name when updating; only overwrite if the
2688  	// skill is brand-new (Name was never set). The URL slug must not
2689  	// replace a carefully chosen display label like "Docker".
2690  	if skillToWrite.Name == "" {
2691  		skillToWrite.Name = name
2692  	}
2693  	skillToWrite.Description = req.Description
2694  	skillToWrite.Prompt = req.Prompt
2695  	skillToWrite.License = req.License
2696  	if req.StickyInstructions != nil {
2697  		skillToWrite.StickyInstructions = *req.StickyInstructions
2698  	}
2699  	if req.StickySnippet != nil {
2700  		skillToWrite.StickySnippetOverride = strings.TrimSpace(*req.StickySnippet)
2701  	}
2702  	if err := skills.WriteGlobalSkill(s.deps.ShannonDir, &skillToWrite); err != nil {
2703  		writeError(w, http.StatusInternalServerError, err.Error())
2704  		return
2705  	}
2706  	s.auditHTTPOp("PUT", "/skills/"+name, "wrote global skill")
2707  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
2708  }
2709  
2710  func (s *Server) handleDeleteGlobalSkill(w http.ResponseWriter, r *http.Request) {
2711  	if requireConfirm(r.URL.Query().Get("confirm")) {
2712  		writeJSON(w, http.StatusBadRequest, map[string]string{
2713  			"error":   "confirmation_required",
2714  			"message": "This will permanently delete the skill. Add ?confirm=true to proceed.",
2715  		})
2716  		return
2717  	}
2718  	name := r.PathValue("name")
2719  	if err := skills.ValidateSkillName(name); err != nil {
2720  		writeError(w, http.StatusBadRequest, err.Error())
2721  		return
2722  	}
2723  	if s.deps == nil {
2724  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
2725  		return
2726  	}
2727  	globalDir := filepath.Join(s.deps.ShannonDir, "skills", name)
2728  	skillFile := filepath.Join(globalDir, "SKILL.md")
2729  	if _, err := os.Stat(skillFile); err != nil {
2730  		writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in global directory", name))
2731  		return
2732  	}
2733  	if err := skills.DeleteGlobalSkill(s.deps.ShannonDir, name); err != nil {
2734  		writeError(w, http.StatusInternalServerError, err.Error())
2735  		return
2736  	}
2737  	s.auditHTTPOp("DELETE", "/skills/"+name, "deleted skill")
2738  	s.secretsStore.Delete(name)
2739  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2740  }
2741  
2742  func (s *Server) handlePutSkillSecrets(w http.ResponseWriter, r *http.Request) {
2743  	name := r.PathValue("name")
2744  	if err := skills.ValidateSkillName(name); err != nil {
2745  		writeError(w, http.StatusBadRequest, err.Error())
2746  		return
2747  	}
2748  	var secrets map[string]string
2749  	if !decodeBody(w, r, &secrets) {
2750  		return
2751  	}
2752  	if len(secrets) == 0 {
2753  		writeError(w, http.StatusBadRequest, "no secrets provided")
2754  		return
2755  	}
2756  	keys := make([]string, 0, len(secrets))
2757  	for key := range secrets {
2758  		if !skills.IsValidEnvKey(key) {
2759  			writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid secret key %q: must match [A-Z0-9_]+", key))
2760  			return
2761  		}
2762  		keys = append(keys, key)
2763  	}
2764  	sort.Strings(keys)
2765  	if err := s.secretsStore.Set(name, secrets); err != nil {
2766  		writeError(w, http.StatusInternalServerError, err.Error())
2767  		return
2768  	}
2769  	s.auditHTTPOp("PUT", "/skills/"+name+"/secrets", "set secrets for skill: "+strings.Join(keys, ","))
2770  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
2771  }
2772  
2773  func (s *Server) handleDeleteSkillSecrets(w http.ResponseWriter, r *http.Request) {
2774  	name := r.PathValue("name")
2775  	if err := skills.ValidateSkillName(name); err != nil {
2776  		writeError(w, http.StatusBadRequest, err.Error())
2777  		return
2778  	}
2779  	if err := s.secretsStore.Delete(name); err != nil {
2780  		writeError(w, http.StatusInternalServerError, err.Error())
2781  		return
2782  	}
2783  	s.auditHTTPOp("DELETE", "/skills/"+name+"/secrets", "cleared all secrets for skill")
2784  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2785  }
2786  
2787  func (s *Server) handleDeleteSkillSecretKey(w http.ResponseWriter, r *http.Request) {
2788  	name := r.PathValue("name")
2789  	key := r.PathValue("key")
2790  	if err := skills.ValidateSkillName(name); err != nil {
2791  		writeError(w, http.StatusBadRequest, err.Error())
2792  		return
2793  	}
2794  	if key == "" {
2795  		writeError(w, http.StatusBadRequest, "key is required")
2796  		return
2797  	}
2798  	if err := s.secretsStore.DeleteKey(name, key); err != nil {
2799  		writeError(w, http.StatusInternalServerError, err.Error())
2800  		return
2801  	}
2802  	s.auditHTTPOp("DELETE", "/skills/"+name+"/secrets/"+key, "removed secret key")
2803  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2804  }
2805  
2806  func (s *Server) handleListSkillScripts(w http.ResponseWriter, r *http.Request) {
2807  	s.handleListSkillSubresource(w, r, "scripts")
2808  }
2809  
2810  func (s *Server) handlePutSkillScripts(w http.ResponseWriter, r *http.Request) {
2811  	s.handlePutSkillSubresource(w, r, "scripts")
2812  }
2813  
2814  func (s *Server) handleDeleteSkillScripts(w http.ResponseWriter, r *http.Request) {
2815  	s.handleDeleteSkillSubresource(w, r, "scripts")
2816  }
2817  
2818  func (s *Server) handleListSkillReferences(w http.ResponseWriter, r *http.Request) {
2819  	s.handleListSkillSubresource(w, r, "references")
2820  }
2821  
2822  func (s *Server) handlePutSkillReferences(w http.ResponseWriter, r *http.Request) {
2823  	s.handlePutSkillSubresource(w, r, "references")
2824  }
2825  
2826  func (s *Server) handleDeleteSkillReferences(w http.ResponseWriter, r *http.Request) {
2827  	s.handleDeleteSkillSubresource(w, r, "references")
2828  }
2829  
2830  func (s *Server) handleListSkillAssets(w http.ResponseWriter, r *http.Request) {
2831  	s.handleListSkillSubresource(w, r, "assets")
2832  }
2833  
2834  func (s *Server) handlePutSkillAssets(w http.ResponseWriter, r *http.Request) {
2835  	s.handlePutSkillSubresource(w, r, "assets")
2836  }
2837  
2838  func (s *Server) handleDeleteSkillAssets(w http.ResponseWriter, r *http.Request) {
2839  	s.handleDeleteSkillSubresource(w, r, "assets")
2840  }
2841  
2842  func (s *Server) handleListSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) {
2843  	name := r.PathValue("name")
2844  	if err := skills.ValidateSkillName(name); err != nil {
2845  		writeError(w, http.StatusBadRequest, err.Error())
2846  		return
2847  	}
2848  	dir, _, _, err := s.resolveSkillDir(name)
2849  	if err != nil {
2850  		if os.IsNotExist(err) {
2851  			writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name))
2852  			return
2853  		}
2854  		writeError(w, http.StatusInternalServerError, err.Error())
2855  		return
2856  	}
2857  	target := filepath.Join(dir, subdir)
2858  	entries, err := os.ReadDir(target)
2859  	if err != nil {
2860  		if os.IsNotExist(err) {
2861  			writeJSON(w, http.StatusOK, map[string][]string{"files": []string{}})
2862  			return
2863  		}
2864  		writeError(w, http.StatusInternalServerError, err.Error())
2865  		return
2866  	}
2867  	files := make([]string, 0, len(entries))
2868  	for _, entry := range entries {
2869  		if entry.IsDir() {
2870  			continue
2871  		}
2872  		files = append(files, entry.Name())
2873  	}
2874  	sort.Strings(files)
2875  	writeJSON(w, http.StatusOK, map[string][]string{"files": files})
2876  }
2877  
2878  func (s *Server) handlePutSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) {
2879  	name := r.PathValue("name")
2880  	if err := skills.ValidateSkillName(name); err != nil {
2881  		writeError(w, http.StatusBadRequest, err.Error())
2882  		return
2883  	}
2884  	dir, _, readOnly, err := s.resolveSkillDir(name)
2885  	if err != nil {
2886  		if os.IsNotExist(err) {
2887  			writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name))
2888  			return
2889  		}
2890  		writeError(w, http.StatusInternalServerError, err.Error())
2891  		return
2892  	}
2893  	if readOnly {
2894  		writeError(w, http.StatusBadRequest, "bundled skill is read-only; create a global override first via PUT /skills/{name}")
2895  		return
2896  	}
2897  	filename := r.PathValue("filename")
2898  	if !isValidSkillFileName(filename) {
2899  		writeError(w, http.StatusBadRequest, "invalid filename")
2900  		return
2901  	}
2902  	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
2903  	data, err := io.ReadAll(r.Body)
2904  	if err != nil {
2905  		var maxErr *http.MaxBytesError
2906  		if errors.As(err, &maxErr) {
2907  			writeError(w, http.StatusRequestEntityTooLarge, "request body too large")
2908  			return
2909  		}
2910  		writeError(w, http.StatusInternalServerError, err.Error())
2911  		return
2912  	}
2913  	targetDir := filepath.Join(dir, subdir)
2914  	if err := os.MkdirAll(targetDir, 0700); err != nil {
2915  		writeError(w, http.StatusInternalServerError, err.Error())
2916  		return
2917  	}
2918  	fileMode := modeForSubresource(subdir)
2919  	tmp, err := os.CreateTemp(targetDir, ".skill-file-*")
2920  	if err != nil {
2921  		writeError(w, http.StatusInternalServerError, err.Error())
2922  		return
2923  	}
2924  	tmpPath := tmp.Name()
2925  	if _, err := tmp.Write(data); err != nil {
2926  		tmp.Close()
2927  		os.Remove(tmpPath)
2928  		writeError(w, http.StatusInternalServerError, err.Error())
2929  		return
2930  	}
2931  	if err := tmp.Close(); err != nil {
2932  		os.Remove(tmpPath)
2933  		writeError(w, http.StatusInternalServerError, err.Error())
2934  		return
2935  	}
2936  	if err := os.Chmod(tmpPath, fileMode); err != nil {
2937  		os.Remove(tmpPath)
2938  		writeError(w, http.StatusInternalServerError, err.Error())
2939  		return
2940  	}
2941  	dest := filepath.Join(targetDir, filename)
2942  	if err := os.Rename(tmpPath, dest); err != nil {
2943  		os.Remove(tmpPath)
2944  		writeError(w, http.StatusInternalServerError, err.Error())
2945  		return
2946  	}
2947  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
2948  }
2949  
2950  func (s *Server) handleDeleteSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) {
2951  	name := r.PathValue("name")
2952  	if err := skills.ValidateSkillName(name); err != nil {
2953  		writeError(w, http.StatusBadRequest, err.Error())
2954  		return
2955  	}
2956  	dir, _, readOnly, err := s.resolveSkillDir(name)
2957  	if err != nil {
2958  		if os.IsNotExist(err) {
2959  			writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name))
2960  			return
2961  		}
2962  		writeError(w, http.StatusInternalServerError, err.Error())
2963  		return
2964  	}
2965  	if readOnly {
2966  		writeError(w, http.StatusBadRequest, "bundled skill is read-only; create a global override first via PUT /skills/{name}")
2967  		return
2968  	}
2969  	filename := r.PathValue("filename")
2970  	if !isValidSkillFileName(filename) {
2971  		writeError(w, http.StatusBadRequest, "invalid filename")
2972  		return
2973  	}
2974  	if err := os.Remove(filepath.Join(dir, subdir, filename)); err != nil && !os.IsNotExist(err) {
2975  		writeError(w, http.StatusInternalServerError, err.Error())
2976  		return
2977  	}
2978  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
2979  }
2980  
2981  // --- Schedule handlers ---
2982  
2983  func (s *Server) handleListSchedules(w http.ResponseWriter, r *http.Request) {
2984  	if s.deps == nil || s.deps.ScheduleManager == nil {
2985  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
2986  		return
2987  	}
2988  	list, err := s.deps.ScheduleManager.List()
2989  	if err != nil {
2990  		writeError(w, http.StatusInternalServerError, err.Error())
2991  		return
2992  	}
2993  	if list == nil {
2994  		list = []schedule.Schedule{}
2995  	}
2996  	writeJSON(w, http.StatusOK, map[string]interface{}{"schedules": list})
2997  }
2998  
2999  func (s *Server) handleGetSchedule(w http.ResponseWriter, r *http.Request) {
3000  	if s.deps == nil || s.deps.ScheduleManager == nil {
3001  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
3002  		return
3003  	}
3004  	id := r.PathValue("id")
3005  	sched, err := s.deps.ScheduleManager.Get(id)
3006  	if err != nil {
3007  		if strings.Contains(err.Error(), "not found") {
3008  			writeError(w, http.StatusNotFound, err.Error())
3009  			return
3010  		}
3011  		writeError(w, http.StatusInternalServerError, err.Error())
3012  		return
3013  	}
3014  	writeJSON(w, http.StatusOK, sched)
3015  }
3016  
3017  func (s *Server) handleCreateSchedule(w http.ResponseWriter, r *http.Request) {
3018  	if s.deps == nil || s.deps.ScheduleManager == nil {
3019  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
3020  		return
3021  	}
3022  	var req struct {
3023  		Agent  string `json:"agent"`
3024  		Cron   string `json:"cron"`
3025  		Prompt string `json:"prompt"`
3026  	}
3027  	if !decodeBody(w, r, &req) {
3028  		return
3029  	}
3030  	id, err := s.deps.ScheduleManager.Create(req.Agent, req.Cron, req.Prompt)
3031  	if err != nil {
3032  		if strings.Contains(err.Error(), "not found") {
3033  			writeError(w, http.StatusNotFound, err.Error())
3034  		} else if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "prompt cannot be empty") {
3035  			writeError(w, http.StatusBadRequest, err.Error())
3036  		} else {
3037  			writeError(w, http.StatusInternalServerError, err.Error())
3038  		}
3039  		return
3040  	}
3041  	sched, err := s.deps.ScheduleManager.Get(id)
3042  	if err != nil {
3043  		writeError(w, http.StatusInternalServerError, err.Error())
3044  		return
3045  	}
3046  	writeJSON(w, http.StatusCreated, sched)
3047  }
3048  
3049  func (s *Server) handlePatchSchedule(w http.ResponseWriter, r *http.Request) {
3050  	if s.deps == nil || s.deps.ScheduleManager == nil {
3051  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
3052  		return
3053  	}
3054  	id := r.PathValue("id")
3055  	var patch struct {
3056  		Cron    *string `json:"cron"`
3057  		Prompt  *string `json:"prompt"`
3058  		Enabled *bool   `json:"enabled"`
3059  	}
3060  	if !decodeBody(w, r, &patch) {
3061  		return
3062  	}
3063  	if patch.Cron == nil && patch.Prompt == nil && patch.Enabled == nil {
3064  		writeError(w, http.StatusBadRequest, "no fields to update")
3065  		return
3066  	}
3067  	update := &schedule.UpdateOpts{
3068  		Cron:    patch.Cron,
3069  		Prompt:  patch.Prompt,
3070  		Enabled: patch.Enabled,
3071  	}
3072  	if err := s.deps.ScheduleManager.Update(id, update); err != nil {
3073  		if strings.Contains(err.Error(), "not found") {
3074  			writeError(w, http.StatusNotFound, err.Error())
3075  			return
3076  		}
3077  		if strings.Contains(err.Error(), "no fields to update") ||
3078  			strings.Contains(err.Error(), "invalid") ||
3079  			strings.Contains(err.Error(), "prompt cannot be empty") {
3080  			writeError(w, http.StatusBadRequest, err.Error())
3081  			return
3082  		}
3083  		writeError(w, http.StatusInternalServerError, err.Error())
3084  		return
3085  	}
3086  	sched, err := s.deps.ScheduleManager.Get(id)
3087  	if err != nil {
3088  		writeError(w, http.StatusInternalServerError, err.Error())
3089  		return
3090  	}
3091  	writeJSON(w, http.StatusOK, sched)
3092  }
3093  
3094  func (s *Server) handleDeleteSchedule(w http.ResponseWriter, r *http.Request) {
3095  	if requireConfirm(r.URL.Query().Get("confirm")) {
3096  		writeJSON(w, http.StatusBadRequest, map[string]string{
3097  			"error":   "confirmation_required",
3098  			"message": "This will permanently delete the schedule. Add ?confirm=true to proceed.",
3099  		})
3100  		return
3101  	}
3102  	if s.deps == nil || s.deps.ScheduleManager == nil {
3103  		writeError(w, http.StatusInternalServerError, "daemon deps not configured")
3104  		return
3105  	}
3106  	id := r.PathValue("id")
3107  	if err := s.deps.ScheduleManager.Remove(id); err != nil {
3108  		if strings.Contains(err.Error(), "not found") {
3109  			writeError(w, http.StatusNotFound, err.Error())
3110  			return
3111  		}
3112  		writeError(w, http.StatusInternalServerError, err.Error())
3113  		return
3114  	}
3115  	s.auditHTTPOp("DELETE", "/schedules/"+id, "deleted schedule")
3116  	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
3117  }
3118  
3119  // --- Global config + instructions handlers ---
3120  
3121  func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
3122  	globalPath := filepath.Join(s.deps.ShannonDir, "config.yaml")
3123  	globalData, err := os.ReadFile(globalPath)
3124  	var globalMap map[string]interface{}
3125  	if err == nil {
3126  		if yamlErr := yaml.Unmarshal(globalData, &globalMap); yamlErr != nil {
3127  			log.Printf("daemon: GET /config: global config parse error: %v", yamlErr)
3128  		}
3129  	}
3130  
3131  	cfg, _, _ := s.deps.Snapshot()
3132  	effectiveJSON, _ := json.Marshal(cfg)
3133  	var effectiveMap map[string]interface{}
3134  	json.Unmarshal(effectiveJSON, &effectiveMap)
3135  
3136  	// Redact secrets from both maps before responding
3137  	redactConfigSecrets(globalMap)
3138  	redactConfigSecrets(effectiveMap)
3139  
3140  	// Collect unique source files from config merge
3141  	var sources []string
3142  	if cfg != nil && cfg.Sources != nil {
3143  		seen := make(map[string]bool)
3144  		for _, src := range cfg.Sources {
3145  			if src.File != "" && !seen[src.File] {
3146  				seen[src.File] = true
3147  				sources = append(sources, src.File)
3148  			}
3149  		}
3150  	}
3151  
3152  	writeJSON(w, http.StatusOK, map[string]interface{}{
3153  		"global":    globalMap,
3154  		"effective": effectiveMap,
3155  		"sources":   sources,
3156  	})
3157  }
3158  
3159  func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
3160  	var patch map[string]interface{}
3161  	if !decodeBody(w, r, &patch) {
3162  		return
3163  	}
3164  
3165  	// Normalize aliases FIRST so security checks see canonical keys.
3166  	// e.g. "apiKey" → "api_key", "mcpServers" → "mcp_servers".
3167  	normalizePatchKeys(patch)
3168  
3169  	// Check protected fields — hard block, no bypass header.
3170  	// These fields must be edited directly in ~/.shannon/config.yaml.
3171  	if reason, isProtected := checkProtectedFields(patch); isProtected {
3172  		writeJSON(w, http.StatusConflict, map[string]string{
3173  			"error":   "protected_field",
3174  			"message": reason + " — edit ~/.shannon/config.yaml directly",
3175  		})
3176  		return
3177  	}
3178  
3179  	// Validate MCP server commands
3180  	if servers, ok := patch["mcp_servers"].(map[string]interface{}); ok {
3181  		confirmed := r.Header.Get("X-Confirm") != ""
3182  		if err := validateMCPCommands(servers, confirmed); err != nil {
3183  			writeJSON(w, http.StatusConflict, map[string]string{
3184  				"error":   "mcp_command_validation",
3185  				"message": err.Error(),
3186  			})
3187  			return
3188  		}
3189  	}
3190  
3191  	if err := s.patchGlobalConfig(patch); err != nil {
3192  		writeError(w, http.StatusInternalServerError, err.Error())
3193  		return
3194  	}
3195  	s.auditHTTPOp("PATCH", "/config", "updated config")
3196  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
3197  }
3198  
3199  // handleConfigStatus returns current MCP server status without restarting processes.
3200  func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) {
3201  	cfg, _, _ := s.deps.Snapshot()
3202  	resp := make(map[string]interface{})
3203  
3204  	if cfg != nil && len(cfg.MCPServers) > 0 {
3205  		// Build set of live-connected server names from MCPManager.
3206  		s.deps.mu.RLock()
3207  		mgr := s.deps.MCPManager
3208  		s.deps.mu.RUnlock()
3209  		connected := make(map[string]bool)
3210  		if mgr != nil {
3211  			for _, name := range mgr.ConnectedServers() {
3212  				connected[name] = true
3213  			}
3214  		}
3215  
3216  		mcpStatus := make(map[string]string, len(cfg.MCPServers))
3217  		for name, srv := range cfg.MCPServers {
3218  			if srv.Disabled {
3219  				mcpStatus[name] = "disabled"
3220  			} else if connected[name] {
3221  				mcpStatus[name] = "connected"
3222  			} else {
3223  				mcpStatus[name] = "enabled"
3224  			}
3225  		}
3226  		resp["mcp_servers"] = mcpStatus
3227  	}
3228  
3229  	_, _, sup := s.deps.Snapshot()
3230  	if sup != nil {
3231  		healthData := make(map[string]interface{})
3232  		for name, h := range sup.HealthStates() {
3233  			entry := map[string]interface{}{
3234  				"state":                h.State.String(),
3235  				"since":                h.Since.Format(time.RFC3339),
3236  				"consecutive_failures": h.ConsecutiveFailures,
3237  			}
3238  			if !h.LastTransportOK.IsZero() {
3239  				entry["last_transport_ok"] = h.LastTransportOK.Format(time.RFC3339)
3240  			}
3241  			if !h.LastCapabilityOK.IsZero() {
3242  				entry["last_capability_ok"] = h.LastCapabilityOK.Format(time.RFC3339)
3243  			}
3244  			if h.LastTransportError != "" {
3245  				entry["last_transport_error"] = h.LastTransportError
3246  			}
3247  			if h.LastCapabilityError != "" {
3248  				entry["last_capability_error"] = h.LastCapabilityError
3249  			}
3250  			healthData[name] = entry
3251  		}
3252  		if len(healthData) > 0 {
3253  			resp["mcp_health"] = healthData
3254  		}
3255  	}
3256  
3257  	writeJSON(w, http.StatusOK, resp)
3258  }
3259  
3260  // mcpConfigChanged returns true if MCP server configuration differs between old and new config.
3261  func mcpConfigChanged(oldCfg, newCfg *config.Config) bool {
3262  	if oldCfg == nil {
3263  		return len(newCfg.MCPServers) > 0
3264  	}
3265  	if len(oldCfg.MCPServers) != len(newCfg.MCPServers) {
3266  		return true
3267  	}
3268  	for name, oldSrv := range oldCfg.MCPServers {
3269  		newSrv, ok := newCfg.MCPServers[name]
3270  		if !ok {
3271  			return true
3272  		}
3273  		if oldSrv.Command != newSrv.Command || oldSrv.Type != newSrv.Type ||
3274  			oldSrv.URL != newSrv.URL || oldSrv.Disabled != newSrv.Disabled ||
3275  			oldSrv.Context != newSrv.Context || !slices.Equal(oldSrv.Args, newSrv.Args) ||
3276  			!maps.Equal(oldSrv.Env, newSrv.Env) {
3277  			return true
3278  		}
3279  	}
3280  	return false
3281  }
3282  
3283  func (s *Server) handleConfigReload(w http.ResponseWriter, r *http.Request) {
3284  	oldCfg, _, _ := s.deps.Snapshot()
3285  
3286  	newCfg, err := config.Load()
3287  	if err != nil {
3288  		writeError(w, http.StatusInternalServerError, fmt.Sprintf("config load failed: %v", err))
3289  		return
3290  	}
3291  
3292  	mcpChanged := mcpConfigChanged(oldCfg, newCfg)
3293  	mcp.SetCDPChromeProfile(newCfg.Daemon.ChromeProfile)
3294  
3295  	var regErr error
3296  	if mcpChanged {
3297  		var newReg *agent.ToolRegistry
3298  		var newMCPMgr *mcp.ClientManager
3299  		var newCleanup func()
3300  		var newBaseline *agent.ToolRegistry
3301  		newBaseline, newReg, _, newMCPMgr, newCleanup, regErr = tools.RegisterAllWithBaseline(s.deps.GW, newCfg)
3302  		if regErr != nil {
3303  			log.Printf("daemon: reload warning: %v", regErr)
3304  		}
3305  		tools.RegisterCloudDelegate(newReg, s.deps.GW, newCfg, nil, "", "")
3306  
3307  		newGatewayOverlay := tools.ExtractGatewayTools(newReg)
3308  		newPostOverlays := tools.ExtractPostOverlays(newReg, newBaseline)
3309  
3310  		newSupervisor := mcp.NewSupervisor(newMCPMgr)
3311  		newSupervisor.RegisterCapabilityProbe("playwright", &mcp.PlaywrightProbe{})
3312  		newSupervisor.SetOnReconnect(func(ctx context.Context, serverName string) {
3313  			if serverName == "playwright" {
3314  				tools.CleanupPlaywrightReconnect(ctx, newMCPMgr)
3315  			}
3316  		})
3317  		newSupervisor.SetOnChange(func(server string, oldState, newState mcp.HealthState) {
3318  			_, _, depsSup := s.deps.Snapshot()
3319  			if depsSup != newSupervisor {
3320  				return
3321  			}
3322  			bl, gwOv, po, mgr := s.deps.RebuildLayers()
3323  			rebuilt := tools.RebuildRegistryForHealth(bl, gwOv, po, newSupervisor.HealthStates(), mgr, newSupervisor)
3324  			s.deps.WriteLock()
3325  			s.deps.Registry = rebuilt
3326  			s.deps.WriteUnlock()
3327  			log.Printf("MCP registry rebuilt (reload): %d tools", len(rebuilt.All()))
3328  		})
3329  
3330  		s.deps.mu.Lock()
3331  		oldCleanup := s.deps.Cleanup
3332  		oldSupervisor := s.deps.Supervisor
3333  		s.deps.Config = newCfg
3334  		s.deps.Registry = newReg
3335  		s.deps.MCPManager = newMCPMgr
3336  		s.deps.Supervisor = newSupervisor
3337  		s.deps.Cleanup = newCleanup
3338  		s.deps.BaselineReg = newBaseline
3339  		s.deps.GatewayOverlay = newGatewayOverlay
3340  		s.deps.PostOverlays = newPostOverlays
3341  		s.deps.mu.Unlock()
3342  
3343  		if oldSupervisor != nil {
3344  			oldSupervisor.Stop()
3345  		}
3346  		if oldCleanup != nil {
3347  			oldCleanup()
3348  		}
3349  
3350  		newSupervisor.Start(s.ctx)
3351  
3352  		// Force registry rebuild to attach supervisor to MCPTools (same
3353  		// reason as initial startup — CompleteRegistration creates tools
3354  		// before the supervisor exists).
3355  		{
3356  			bl, gwOv, po, mgr := s.deps.RebuildLayers()
3357  			initReg := tools.RebuildRegistryForHealth(bl, gwOv, po, newSupervisor.HealthStates(), mgr, newSupervisor)
3358  			s.deps.WriteLock()
3359  			s.deps.Registry = initReg
3360  			s.deps.WriteUnlock()
3361  			log.Printf("MCP registry initialized with supervisor (reload): %d tools", len(initReg.All()))
3362  		}
3363  	} else {
3364  		// Config changed but MCP servers didn't — update config and refresh
3365  		// cached rebuild layers so health-driven rebuilds use current settings.
3366  		newBaseline, _, newBaseCleanup := tools.RegisterLocalTools(newCfg, s.secretsStore)
3367  		// Re-register gateway tools on top of fresh baseline clone.
3368  		// Use a short timeout — if the gateway is unavailable, keep existing overlay.
3369  		freshReg := newBaseline.Clone()
3370  		gwCtx, gwCancel := context.WithTimeout(r.Context(), 5*time.Second)
3371  		gwErr := tools.RegisterServerTools(gwCtx, s.deps.GW, freshReg)
3372  		gwCancel()
3373  		tools.RegisterCloudDelegate(freshReg, s.deps.GW, newCfg, nil, "", "")
3374  		var newGatewayOverlay []agent.Tool
3375  		if gwErr != nil {
3376  			log.Printf("daemon: reload: gateway refresh failed, keeping existing overlay: %v", gwErr)
3377  			s.deps.mu.RLock()
3378  			newGatewayOverlay = s.deps.GatewayOverlay
3379  			s.deps.mu.RUnlock()
3380  		} else {
3381  			newGatewayOverlay = tools.ExtractGatewayTools(freshReg)
3382  		}
3383  		newPostOverlays := tools.ExtractPostOverlays(freshReg, newBaseline)
3384  
3385  		s.deps.mu.Lock()
3386  		oldCleanup := s.deps.Cleanup
3387  		s.deps.Config = newCfg
3388  		s.deps.BaselineReg = newBaseline
3389  		s.deps.GatewayOverlay = newGatewayOverlay
3390  		s.deps.PostOverlays = newPostOverlays
3391  		s.deps.Cleanup = func() { newBaseCleanup(); oldCleanup() }
3392  		s.deps.mu.Unlock()
3393  	}
3394  
3395  	if s.onReload != nil {
3396  		go s.onReload()
3397  	}
3398  
3399  	resp := map[string]interface{}{"status": "reloaded"}
3400  	if oldCfg != nil && (oldCfg.Endpoint != newCfg.Endpoint || oldCfg.APIKey != newCfg.APIKey) {
3401  		resp["restart_required"] = true
3402  		resp["restart_reason"] = "endpoint or api_key changed — restart daemon to apply"
3403  	}
3404  
3405  	// MCP server status for UI indicators
3406  	if len(newCfg.MCPServers) > 0 {
3407  		// Build set of live-connected server names from MCPManager.
3408  		s.deps.mu.RLock()
3409  		mgr := s.deps.MCPManager
3410  		s.deps.mu.RUnlock()
3411  		connected := make(map[string]bool)
3412  		if mgr != nil {
3413  			for _, name := range mgr.ConnectedServers() {
3414  				connected[name] = true
3415  			}
3416  		}
3417  
3418  		mcpStatus := make(map[string]string, len(newCfg.MCPServers))
3419  		for name, srv := range newCfg.MCPServers {
3420  			if srv.Disabled {
3421  				mcpStatus[name] = "disabled"
3422  			} else if connected[name] {
3423  				mcpStatus[name] = "connected"
3424  			} else {
3425  				mcpStatus[name] = "enabled"
3426  			}
3427  		}
3428  		// Mark failed servers from registration error
3429  		if regErr != nil {
3430  			errMsg := regErr.Error()
3431  			for name := range newCfg.MCPServers {
3432  				if newCfg.MCPServers[name].Disabled {
3433  					continue
3434  				}
3435  				if strings.Contains(errMsg, name+":") {
3436  					mcpStatus[name] = "error"
3437  				}
3438  			}
3439  		}
3440  		resp["mcp_servers"] = mcpStatus
3441  	}
3442  
3443  	writeJSON(w, http.StatusOK, resp)
3444  }
3445  
3446  func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) {
3447  	path := filepath.Join(s.deps.ShannonDir, "instructions.md")
3448  	data, err := os.ReadFile(path)
3449  	if os.IsNotExist(err) {
3450  		writeJSON(w, http.StatusOK, map[string]interface{}{"content": nil})
3451  		return
3452  	}
3453  	if err != nil {
3454  		writeError(w, http.StatusInternalServerError, err.Error())
3455  		return
3456  	}
3457  	writeJSON(w, http.StatusOK, map[string]string{"content": string(data)})
3458  }
3459  
3460  func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) {
3461  	var body struct {
3462  		Content *string `json:"content"`
3463  	}
3464  	if !decodeBody(w, r, &body) {
3465  		return
3466  	}
3467  	path := filepath.Join(s.deps.ShannonDir, "instructions.md")
3468  	if body.Content == nil {
3469  		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
3470  			writeError(w, http.StatusInternalServerError, err.Error())
3471  			return
3472  		}
3473  	} else {
3474  		if err := agents.AtomicWrite(path, []byte(*body.Content)); err != nil {
3475  			writeError(w, http.StatusInternalServerError, err.Error())
3476  			return
3477  		}
3478  	}
3479  	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
3480  }
3481  
3482  // syncAuditAdapter bridges the daemon's *audit.AuditLogger (which writes
3483  // AuditEntry rows) to the sync.AuditLogger interface (which emits
3484  // event-name + structured fields). The sync package only ever calls Log;
3485  // we project the fields into AuditEntry.InputSummary so they land in
3486  // audit.log alongside tool-call entries.
3487  type syncAuditAdapter struct {
3488  	logger *audit.AuditLogger
3489  }
3490  
3491  func (a syncAuditAdapter) Log(event string, fields map[string]any) {
3492  	if a.logger == nil {
3493  		return
3494  	}
3495  	// Render fields as a stable, compact string. JSON gives us deterministic
3496  	// formatting and is already what the rest of audit.log uses.
3497  	var summary string
3498  	if data, err := json.Marshal(fields); err == nil {
3499  		summary = string(data)
3500  	} else {
3501  		summary = fmt.Sprintf("%v", fields)
3502  	}
3503  	a.logger.Log(audit.AuditEntry{
3504  		Timestamp:    time.Now(),
3505  		ToolName:     event,
3506  		InputSummary: summary,
3507  		Decision:     "logged",
3508  		Approved:     true,
3509  	})
3510  }
3511  
3512  // runSyncLoop runs sync.Run on a startup-delayed ticker until ctx is canceled.
3513  // Reads config + rebuilds deps on each tick so config changes (enable/disable,
3514  // fixed missing endpoint/api_key) take effect on the next iteration.
3515  //
3516  // Note: the goroutine stays alive even when sync is initially disabled, so
3517  // enabling sync via config edit without restarting the daemon is picked up
3518  // on the next tick. The ticker cadence itself (DaemonInterval) is still read
3519  // once at startup — changing it requires a daemon restart.
3520  func (s *Server) runSyncLoop(ctx context.Context) {
3521  	initialCfg := syncpkg.LoadConfig(viper.GetViper())
3522  	if initialCfg.DaemonInterval <= 0 {
3523  		return // misconfigured: nothing to do
3524  	}
3525  
3526  	// Wait for startup delay, but respect ctx.
3527  	if initialCfg.DaemonStartupDelay > 0 {
3528  		select {
3529  		case <-ctx.Done():
3530  			return
3531  		case <-time.After(initialCfg.DaemonStartupDelay):
3532  		}
3533  	}
3534  
3535  	tick := func() {
3536  		cfg := syncpkg.LoadConfig(viper.GetViper())
3537  		if !cfg.Enabled {
3538  			return // disabled now; cheap to re-check next tick
3539  		}
3540  		deps, ok := s.buildSyncDeps(cfg)
3541  		if !ok {
3542  			return // config incomplete; buildSyncDeps already logged the reason
3543  		}
3544  		if err := syncpkg.Run(ctx, deps); err != nil {
3545  			log.Printf("daemon sync: run error: %v", err)
3546  		}
3547  	}
3548  
3549  	if initialCfg.Enabled {
3550  		tick() // catch-up only if currently enabled
3551  	}
3552  
3553  	t := time.NewTicker(initialCfg.DaemonInterval)
3554  	defer t.Stop()
3555  	for {
3556  		select {
3557  		case <-ctx.Done():
3558  			return
3559  		case <-t.C:
3560  			tick()
3561  		}
3562  	}
3563  }
3564  
3565  // buildSyncDeps returns ok=false if the config is incomplete (missing
3566  // endpoint or api_key in non-dry-run mode). Caller must skip this iteration
3567  // if !ok. This is re-evaluated every tick so a deferred secret load takes
3568  // effect on the next iteration without restarting the daemon.
3569  func (s *Server) buildSyncDeps(cfg syncpkg.Config) (syncpkg.Deps, bool) {
3570  	home, _ := os.UserHomeDir()
3571  	shannonHome := filepath.Join(home, ".shannon")
3572  
3573  	var uploader syncpkg.Uploader
3574  	if cfg.DryRun {
3575  		uploader = &syncpkg.DryRunUploader{
3576  			OutboxDir: filepath.Join(shannonHome, "sync_outbox"),
3577  			Now:       time.Now,
3578  		}
3579  	} else {
3580  		endpoint := syncpkg.ResolveEndpoint(cfg, viper.GetViper())
3581  		apiKey := viper.GetString("cloud.api_key")
3582  		if endpoint == "" || apiKey == "" {
3583  			log.Printf("daemon sync: missing endpoint (sync.endpoint or cloud.endpoint) or cloud.api_key; skipping until configured")
3584  			return syncpkg.Deps{}, false
3585  		}
3586  		gw := client.NewGatewayClient(endpoint, apiKey)
3587  		uploader = &syncpkg.CloudUploader{Client: gw}
3588  	}
3589  
3590  	loader := func(dir, id string) ([]byte, error) {
3591  		return os.ReadFile(filepath.Join(dir, id+".json"))
3592  	}
3593  
3594  	var auditSink syncpkg.AuditLogger
3595  	if s.deps != nil && s.deps.Auditor != nil {
3596  		auditSink = syncAuditAdapter{logger: s.deps.Auditor}
3597  	}
3598  
3599  	return syncpkg.Deps{
3600  		Cfg:       cfg,
3601  		HomeDir:   shannonHome,
3602  		ClientVer: "shanclaw/daemon",
3603  		Uploader:  uploader,
3604  		Loader:    loader,
3605  		Audit:     auditSink,
3606  		Now:       time.Now,
3607  	}, true
3608  }