@@ -11,6 +11,7 @@ import (
1111
1212 "charm.land/catwalk/pkg/catwalk"
1313 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14+ "github.com/charmbracelet/crush/internal/env"
1415 "github.com/charmbracelet/crush/internal/oauth"
1516 "github.com/charmbracelet/crush/internal/oauth/copilot"
1617 "github.com/charmbracelet/crush/internal/oauth/hyper"
@@ -18,6 +19,14 @@ import (
1819 "github.com/tidwall/sjson"
1920)
2021
22+// fileSnapshot captures metadata about a config file at a point in time.
23+type fileSnapshot struct {
24+ Path string
25+ Exists bool
26+ Size int64
27+ ModTime int64 // UnixNano
28+}
29+
2130// RuntimeOverrides holds per-session settings that are never persisted to
2231// disk. They are applied on top of the loaded Config and survive only for
2332// the lifetime of the process (or workspace).
@@ -29,14 +38,16 @@ type RuntimeOverrides struct {
2938// pure-data Config, runtime state (working directory, resolver, known
3039// providers), and persistence to both global and workspace config files.
3140type ConfigStore struct {
32โ config *Config
33โ workingDir string
34โ resolver VariableResolver
35โ globalDataPath string // ~/.local/share/crush/crush.json
36โ workspacePath string // .crush/crush.json
37โ loadedPaths []string // config files that were successfully loaded
38โ knownProviders []catwalk.Provider
39โ overrides RuntimeOverrides
41+ config *Config
42+ workingDir string
43+ resolver VariableResolver
44+ globalDataPath string // ~/.local/share/crush/crush.json
45+ workspacePath string // .crush/crush.json
46+ loadedPaths []string // config files that were successfully loaded
47+ knownProviders []catwalk.Provider
48+ overrides RuntimeOverrides
49+ trackedConfigPaths []string // unique, normalized config file paths
50+ snapshots map[string]fileSnapshot // path -> snapshot at last capture
4051}
4152
4253// Config returns the pure-data config struct (read-only after load).
@@ -383,3 +394,207 @@ func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
383394 slog.Info("GitHub Copilot successfully imported")
384395 return token, true
385396}
397+
398+// StalenessResult contains the result of a staleness check.
399+type StalenessResult struct {
400+ Dirty bool
401+ Changed []string
402+ Missing []string
403+ Errors map[string]error // stat errors by path
404+}
405+
406+// ConfigStaleness checks whether any tracked config files have changed on disk
407+// since the last snapshot. Returns dirty=true if any files changed or went
408+// missing, along with sorted lists of affected paths. Stat errors are
409+// captured in Errors map but still treated as non-existence for dirty detection.
410+func (s *ConfigStore) ConfigStaleness() StalenessResult {
411+ var result StalenessResult
412+ result.Errors = make(map[string]error)
413+
414+ for _, path := range s.trackedConfigPaths {
415+ snapshot, hadSnapshot := s.snapshots[path]
416+
417+ info, err := os.Stat(path)
418+ exists := err == nil && !info.IsDir()
419+
420+ if err != nil && !os.IsNotExist(err) {
421+ // Capture permission/IO errors separately from non-existence
422+ result.Errors[path] = err
423+ result.Dirty = true
424+ }
425+
426+ if !exists {
427+ if hadSnapshot && snapshot.Exists {
428+ // File existed before but now missing
429+ result.Missing = append(result.Missing, path)
430+ result.Dirty = true
431+ }
432+ continue
433+ }
434+
435+ // File exists now
436+ if !hadSnapshot || !snapshot.Exists {
437+ // File didn't exist before but does now
438+ result.Changed = append(result.Changed, path)
439+ result.Dirty = true
440+ continue
441+ }
442+
443+ // Check for content or metadata changes
444+ if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
445+ result.Changed = append(result.Changed, path)
446+ result.Dirty = true
447+ }
448+ }
449+
450+ // Sort for deterministic output
451+ slices.Sort(result.Changed)
452+ slices.Sort(result.Missing)
453+
454+ return result
455+}
456+
457+// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
458+// Call this after reloading config to clear dirty state.
459+func (s *ConfigStore) RefreshStalenessSnapshot() error {
460+ if s.snapshots == nil {
461+ s.snapshots = make(map[string]fileSnapshot)
462+ }
463+
464+ for _, path := range s.trackedConfigPaths {
465+ info, err := os.Stat(path)
466+ exists := err == nil && !info.IsDir()
467+
468+ snapshot := fileSnapshot{
469+ Path: path,
470+ Exists: exists,
471+ }
472+
473+ if exists {
474+ snapshot.Size = info.Size()
475+ snapshot.ModTime = info.ModTime().UnixNano()
476+ }
477+
478+ s.snapshots[path] = snapshot
479+ }
480+
481+ return nil
482+}
483+
484+// CaptureStalenessSnapshot captures snapshots for the given paths, building the
485+// tracked config paths list. Paths are deduplicated and normalized.
486+func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
487+ // Build unique set of normalized paths
488+ seen := make(map[string]struct{})
489+ for _, p := range paths {
490+ if p == "" {
491+ continue
492+ }
493+ // Normalize path
494+ abs, err := filepath.Abs(p)
495+ if err != nil {
496+ abs = p
497+ }
498+ seen[abs] = struct{}{}
499+ }
500+
501+ // Also track workspace and global config paths if set
502+ if s.workspacePath != "" {
503+ abs, err := filepath.Abs(s.workspacePath)
504+ if err == nil {
505+ seen[abs] = struct{}{}
506+ }
507+ }
508+ if s.globalDataPath != "" {
509+ abs, err := filepath.Abs(s.globalDataPath)
510+ if err == nil {
511+ seen[abs] = struct{}{}
512+ }
513+ }
514+
515+ // Build sorted list for deterministic ordering
516+ s.trackedConfigPaths = make([]string, 0, len(seen))
517+ for p := range seen {
518+ s.trackedConfigPaths = append(s.trackedConfigPaths, p)
519+ }
520+ slices.Sort(s.trackedConfigPaths)
521+
522+ // Capture initial snapshots
523+ s.RefreshStalenessSnapshot()
524+}
525+
526+// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
527+func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
528+ s.CaptureStalenessSnapshot(paths)
529+}
530+
531+// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
532+// config safely. It rebuilds the staleness snapshot after successful reload.
533+func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
534+ if s.workingDir == "" {
535+ return fmt.Errorf("cannot reload: working directory not set")
536+ }
537+
538+ configPaths := lookupConfigs(s.workingDir)
539+ cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
540+ if err != nil {
541+ return fmt.Errorf("failed to reload config: %w", err)
542+ }
543+
544+ // Apply defaults (using existing data directory if set)
545+ var dataDir string
546+ if s.config != nil && s.config.Options != nil {
547+ dataDir = s.config.Options.DataDirectory
548+ }
549+ cfg.setDefaults(s.workingDir, dataDir)
550+
551+ // Merge workspace config if present
552+ workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
553+ if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
554+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
555+ if mergeErr == nil {
556+ dataDir := cfg.Options.DataDirectory
557+ *cfg = *merged
558+ cfg.setDefaults(s.workingDir, dataDir)
559+ loadedPaths = append(loadedPaths, workspacePath)
560+ }
561+ }
562+
563+ // Preserve runtime overrides
564+ overrides := s.overrides
565+
566+ // Reconfigure providers
567+ env := env.New()
568+ resolver := NewShellVariableResolver(env)
569+ providers, err := Providers(cfg)
570+ if err != nil {
571+ return fmt.Errorf("failed to load providers during reload: %w", err)
572+ }
573+
574+ if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
575+ return fmt.Errorf("failed to configure providers during reload: %w", err)
576+ }
577+
578+ // Update store state BEFORE running model/agent setup (so they see new config)
579+ s.config = cfg
580+ s.loadedPaths = loadedPaths
581+ s.resolver = resolver
582+ s.knownProviders = providers
583+ s.overrides = overrides
584+ s.workspacePath = workspacePath
585+
586+ // Mirror startup flow: setup models and agents against NEW config
587+ if !cfg.IsConfigured() {
588+ slog.Warn("No providers configured after reload")
589+ } else {
590+ if err := configureSelectedModels(s, providers); err != nil {
591+ return fmt.Errorf("failed to configure selected models during reload: %w", err)
592+ }
593+ s.SetupAgents()
594+ }
595+
596+ // Rebuild staleness tracking
597+ s.captureStalenessSnapshot(loadedPaths)
598+
599+ return nil
600+}