// Package daemon provides the town-level background service for Gas Town. // // The daemon is a simple Go process (not a Claude agent) that: // 1. Pokes agents periodically (heartbeat) // 2. Processes lifecycle requests (cycle, restart, shutdown) // 3. Restarts sessions when agents request cycling // // The daemon is a "dumb scheduler" - all intelligence is in agents. package daemon import ( "encoding/json" "os" "path/filepath" "time" ) // Config holds daemon configuration. type Config struct { // HeartbeatInterval is how often to poke agents. HeartbeatInterval time.Duration `json:"heartbeat_interval"` // TownRoot is the Gas Town workspace root. TownRoot string `json:"town_root"` // LogFile is the path to the daemon log file. LogFile string `json:"log_file"` // PidFile is the path to the PID file. PidFile string `json:"pid_file"` } // DefaultConfig returns the default daemon configuration. func DefaultConfig(townRoot string) *Config { daemonDir := filepath.Join(townRoot, "daemon") return &Config{ HeartbeatInterval: 5 * time.Minute, // Deacon wakes on mail too, no need to poke often TownRoot: townRoot, LogFile: filepath.Join(daemonDir, "daemon.log"), PidFile: filepath.Join(daemonDir, "daemon.pid"), } } // State represents the daemon's runtime state. type State struct { // Running indicates if the daemon is running. Running bool `json:"running"` // PID is the process ID of the daemon. PID int `json:"pid"` // StartedAt is when the daemon started. StartedAt time.Time `json:"started_at"` // LastHeartbeat is when the last heartbeat completed. LastHeartbeat time.Time `json:"last_heartbeat"` // HeartbeatCount is how many heartbeats have completed. HeartbeatCount int64 `json:"heartbeat_count"` } // StateFile returns the path to the state file. func StateFile(townRoot string) string { return filepath.Join(townRoot, "daemon", "state.json") } // LoadState loads daemon state from disk. func LoadState(townRoot string) (*State, error) { stateFile := StateFile(townRoot) data, err := os.ReadFile(stateFile) if err != nil { if os.IsNotExist(err) { return &State{}, nil } return nil, err } var state State if err := json.Unmarshal(data, &state); err != nil { return nil, err } return &state, nil } // SaveState saves daemon state to disk. func SaveState(townRoot string, state *State) error { stateFile := StateFile(townRoot) // Ensure daemon directory exists if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil { return err } data, err := json.MarshalIndent(state, "", " ") if err != nil { return err } return os.WriteFile(stateFile, data, 0644) } // LifecycleAction represents a lifecycle request action. type LifecycleAction string const ( // ActionCycle restarts the session with handoff. ActionCycle LifecycleAction = "cycle" // ActionRestart does a fresh restart without handoff. ActionRestart LifecycleAction = "restart" // ActionShutdown terminates without restart. ActionShutdown LifecycleAction = "shutdown" ) // LifecycleRequest represents a request from an agent to the daemon. type LifecycleRequest struct { // From is the agent requesting the action (e.g., "mayor/", "gastown/witness"). From string `json:"from"` // Action is what lifecycle action to perform. Action LifecycleAction `json:"action"` // Timestamp is when the request was made. Timestamp time.Time `json:"timestamp"` }