// Package config provides configuration types and serialization for Gas Town. package config import ( "encoding/json" "os" "path/filepath" "strings" "sync" ) // AgentPreset identifies a supported LLM agent runtime. // These presets provide sensible defaults that can be overridden in config. type AgentPreset string // Supported agent presets (built-in, E2E tested). const ( // AgentClaude is Claude Code (default). AgentClaude AgentPreset = "claude" // AgentGemini is Gemini CLI. AgentGemini AgentPreset = "gemini" // AgentCodex is OpenAI Codex. AgentCodex AgentPreset = "codex" // AgentCursor is Cursor Agent. AgentCursor AgentPreset = "cursor" // AgentAuggie is Auggie CLI. AgentAuggie AgentPreset = "auggie" // AgentAmp is Sourcegraph AMP. AgentAmp AgentPreset = "amp" ) // AgentPresetInfo contains the configuration details for an agent preset. // This extends the basic RuntimeConfig with agent-specific metadata. type AgentPresetInfo struct { // Name is the preset identifier (e.g., "claude", "gemini", "codex", "cursor", "auggie", "amp"). Name AgentPreset `json:"name"` // Command is the CLI binary to invoke. Command string `json:"command"` // Args are the default command-line arguments for autonomous mode. Args []string `json:"args"` // ProcessNames are the process names to look for when detecting if the agent is running. // Used by tmux.IsAgentRunning to check pane_current_command. // E.g., ["node"] for Claude, ["cursor-agent"] for Cursor. ProcessNames []string `json:"process_names,omitempty"` // SessionIDEnv is the environment variable for session ID. // Used for resuming sessions across restarts. SessionIDEnv string `json:"session_id_env,omitempty"` // ResumeFlag is the flag/subcommand for resuming sessions. // For claude/gemini: "--resume" // For codex: "resume" (subcommand) ResumeFlag string `json:"resume_flag,omitempty"` // ResumeStyle indicates how to invoke resume: // "flag" - pass as --resume argument // "subcommand" - pass as 'codex resume ' ResumeStyle string `json:"resume_style,omitempty"` // SupportsHooks indicates if the agent supports hooks system. SupportsHooks bool `json:"supports_hooks,omitempty"` // SupportsForkSession indicates if --fork-session is available. // Claude-only feature for seance command. SupportsForkSession bool `json:"supports_fork_session,omitempty"` // NonInteractive contains settings for non-interactive mode. NonInteractive *NonInteractiveConfig `json:"non_interactive,omitempty"` } // NonInteractiveConfig contains settings for running agents non-interactively. type NonInteractiveConfig struct { // Subcommand is the subcommand for non-interactive execution (e.g., "exec" for codex). Subcommand string `json:"subcommand,omitempty"` // PromptFlag is the flag for passing prompts (e.g., "-p" for gemini). PromptFlag string `json:"prompt_flag,omitempty"` // OutputFlag is the flag for structured output (e.g., "--json", "--output-format json"). OutputFlag string `json:"output_flag,omitempty"` } // AgentRegistry contains all known agent presets. // Can be loaded from JSON config or use built-in defaults. type AgentRegistry struct { // Version is the schema version for the registry. Version int `json:"version"` // Agents maps agent names to their configurations. Agents map[string]*AgentPresetInfo `json:"agents"` } // CurrentAgentRegistryVersion is the current schema version. const CurrentAgentRegistryVersion = 1 // builtinPresets contains the default presets for supported agents. var builtinPresets = map[AgentPreset]*AgentPresetInfo{ AgentClaude: { Name: AgentClaude, Command: "claude", Args: []string{"--dangerously-skip-permissions"}, ProcessNames: []string{"node"}, // Claude runs as Node.js SessionIDEnv: "CLAUDE_SESSION_ID", ResumeFlag: "--resume", ResumeStyle: "flag", SupportsHooks: true, SupportsForkSession: true, NonInteractive: nil, // Claude is native non-interactive }, AgentGemini: { Name: AgentGemini, Command: "gemini", Args: []string{"--approval-mode", "yolo"}, ProcessNames: []string{"gemini"}, // Gemini CLI binary SessionIDEnv: "GEMINI_SESSION_ID", ResumeFlag: "--resume", ResumeStyle: "flag", SupportsHooks: true, SupportsForkSession: false, NonInteractive: &NonInteractiveConfig{ PromptFlag: "-p", OutputFlag: "--output-format json", }, }, AgentCodex: { Name: AgentCodex, Command: "codex", Args: []string{"--yolo"}, ProcessNames: []string{"codex"}, // Codex CLI binary SessionIDEnv: "", // Codex captures from JSONL output ResumeFlag: "resume", ResumeStyle: "subcommand", SupportsHooks: false, // Use env/files instead SupportsForkSession: false, NonInteractive: &NonInteractiveConfig{ Subcommand: "exec", OutputFlag: "--json", }, }, AgentCursor: { Name: AgentCursor, Command: "cursor-agent", Args: []string{"-f"}, // Force mode (YOLO equivalent), -p requires prompt ProcessNames: []string{"cursor-agent"}, SessionIDEnv: "", // Uses --resume with chatId directly ResumeFlag: "--resume", ResumeStyle: "flag", SupportsHooks: false, // TODO: verify hooks support SupportsForkSession: false, NonInteractive: &NonInteractiveConfig{ PromptFlag: "-p", OutputFlag: "--output-format json", }, }, AgentAuggie: { Name: AgentAuggie, Command: "auggie", Args: []string{"--allow-indexing"}, ProcessNames: []string{"auggie"}, SessionIDEnv: "", ResumeFlag: "--resume", ResumeStyle: "flag", SupportsHooks: false, SupportsForkSession: false, }, AgentAmp: { Name: AgentAmp, Command: "amp", Args: []string{"--dangerously-allow-all", "--no-ide"}, ProcessNames: []string{"amp"}, SessionIDEnv: "", ResumeFlag: "threads continue", ResumeStyle: "subcommand", // 'amp threads continue ' SupportsHooks: false, SupportsForkSession: false, }, } // Registry state with proper synchronization. var ( // registryMu protects all registry state. registryMu sync.RWMutex // globalRegistry is the merged registry of built-in and user-defined agents. globalRegistry *AgentRegistry // loadedPaths tracks which config files have been loaded to avoid redundant reads. loadedPaths = make(map[string]bool) // registryInitialized tracks if builtins have been copied. registryInitialized bool ) // initRegistry initializes the global registry with built-in presets. // Caller must hold registryMu write lock. func initRegistryLocked() { if registryInitialized { return } globalRegistry = &AgentRegistry{ Version: CurrentAgentRegistryVersion, Agents: make(map[string]*AgentPresetInfo), } // Copy built-in presets for name, preset := range builtinPresets { globalRegistry.Agents[string(name)] = preset } registryInitialized = true } // ensureRegistry ensures the registry is initialized for read operations. func ensureRegistry() { registryMu.Lock() defer registryMu.Unlock() initRegistryLocked() } // loadAgentRegistryFromPath loads agent definitions from a JSON file and merges with built-ins. // Caller must hold registryMu write lock. func loadAgentRegistryFromPathLocked(path string) error { initRegistryLocked() if loadedPaths[path] { return nil } data, err := os.ReadFile(path) //nolint:gosec // G304: path is from config if err != nil { if os.IsNotExist(err) { loadedPaths[path] = true return nil } return err } var userRegistry AgentRegistry if err := json.Unmarshal(data, &userRegistry); err != nil { return err } for name, preset := range userRegistry.Agents { preset.Name = AgentPreset(name) globalRegistry.Agents[name] = preset } loadedPaths[path] = true return nil } // LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins. // User-defined agents override built-in presets with the same name. // This function caches loaded paths to avoid redundant file reads. func LoadAgentRegistry(path string) error { registryMu.Lock() defer registryMu.Unlock() return loadAgentRegistryFromPathLocked(path) } // DefaultAgentRegistryPath returns the default path for agent registry. // Located alongside other town settings. func DefaultAgentRegistryPath(townRoot string) string { return filepath.Join(townRoot, "settings", "agents.json") } // DefaultRigAgentRegistryPath returns the default path for rig-level agent registry. // Located in /settings/agents.json. func DefaultRigAgentRegistryPath(rigPath string) string { return filepath.Join(rigPath, "settings", "agents.json") } // RigAgentRegistryPath returns the path for rig-level agent registry. // Alias for DefaultRigAgentRegistryPath for consistency with other path functions. func RigAgentRegistryPath(rigPath string) string { return DefaultRigAgentRegistryPath(rigPath) } // LoadRigAgentRegistry loads agent definitions from a rig-level JSON file and merges with built-ins. // This function works similarly to LoadAgentRegistry but for rig-level configurations. func LoadRigAgentRegistry(path string) error { registryMu.Lock() defer registryMu.Unlock() return loadAgentRegistryFromPathLocked(path) } // GetAgentPreset returns the preset info for a given agent name. // Returns nil if the preset is not found. func GetAgentPreset(name AgentPreset) *AgentPresetInfo { ensureRegistry() registryMu.RLock() defer registryMu.RUnlock() return globalRegistry.Agents[string(name)] } // GetAgentPresetByName returns the preset info by string name. // Returns nil if not found, allowing caller to fall back to defaults. func GetAgentPresetByName(name string) *AgentPresetInfo { ensureRegistry() registryMu.RLock() defer registryMu.RUnlock() return globalRegistry.Agents[name] } // ListAgentPresets returns all known agent preset names. func ListAgentPresets() []string { ensureRegistry() registryMu.RLock() defer registryMu.RUnlock() names := make([]string, 0, len(globalRegistry.Agents)) for name := range globalRegistry.Agents { names = append(names, name) } return names } // DefaultAgentPreset returns the default agent preset (Claude). func DefaultAgentPreset() AgentPreset { return AgentClaude } // RuntimeConfigFromPreset creates a RuntimeConfig from an agent preset. // This provides the basic Command/Args; additional fields from AgentPresetInfo // can be accessed separately for extended functionality. func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig { info := GetAgentPreset(preset) if info == nil { // Fall back to Claude defaults return DefaultRuntimeConfig() } return &RuntimeConfig{ Command: info.Command, Args: append([]string(nil), info.Args...), // Copy to avoid mutation } } // BuildResumeCommand builds a command to resume an agent session. // Returns the full command string including any YOLO/autonomous flags. // If sessionID is empty or the agent doesn't support resume, returns empty string. func BuildResumeCommand(agentName, sessionID string) string { if sessionID == "" { return "" } info := GetAgentPresetByName(agentName) if info == nil || info.ResumeFlag == "" { return "" } // Build base command with args args := append([]string(nil), info.Args...) // Add resume based on style switch info.ResumeStyle { case "subcommand": // e.g., "codex resume --yolo" return info.Command + " " + info.ResumeFlag + " " + sessionID + " " + strings.Join(args, " ") case "flag": fallthrough default: // e.g., "claude --dangerously-skip-permissions --resume " args = append(args, info.ResumeFlag, sessionID) return info.Command + " " + strings.Join(args, " ") } } // SupportsSessionResume checks if an agent supports session resumption. func SupportsSessionResume(agentName string) bool { info := GetAgentPresetByName(agentName) return info != nil && info.ResumeFlag != "" } // GetSessionIDEnvVar returns the environment variable name for storing session IDs // for a given agent. Returns empty string if the agent doesn't use env vars for this. func GetSessionIDEnvVar(agentName string) string { info := GetAgentPresetByName(agentName) if info == nil { return "" } return info.SessionIDEnv } // GetProcessNames returns the process names used to detect if an agent is running. // Used by tmux.IsAgentRunning to check pane_current_command. // Returns ["node"] for Claude (default) if agent is not found or has no ProcessNames. func GetProcessNames(agentName string) []string { info := GetAgentPresetByName(agentName) if info == nil || len(info.ProcessNames) == 0 { // Default to Claude's process name for backwards compatibility return []string{"node"} } return info.ProcessNames } // MergeWithPreset applies preset defaults to a RuntimeConfig. // User-specified values take precedence over preset defaults. // Returns a new RuntimeConfig without modifying the original. func (rc *RuntimeConfig) MergeWithPreset(preset AgentPreset) *RuntimeConfig { if rc == nil { return RuntimeConfigFromPreset(preset) } info := GetAgentPreset(preset) if info == nil { return rc } result := &RuntimeConfig{ Command: rc.Command, Args: append([]string(nil), rc.Args...), InitialPrompt: rc.InitialPrompt, } // Apply preset defaults only if not overridden if result.Command == "" { result.Command = info.Command } if len(result.Args) == 0 { result.Args = append([]string(nil), info.Args...) } return result } // IsKnownPreset checks if a string is a known agent preset name. func IsKnownPreset(name string) bool { ensureRegistry() registryMu.RLock() defer registryMu.RUnlock() _, ok := globalRegistry.Agents[name] return ok } // SaveAgentRegistry writes the agent registry to a file. func SaveAgentRegistry(path string, registry *AgentRegistry) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(registry, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) //nolint:gosec // G306: config file } // NewExampleAgentRegistry creates an example registry with comments. func NewExampleAgentRegistry() *AgentRegistry { return &AgentRegistry{ Version: CurrentAgentRegistryVersion, Agents: map[string]*AgentPresetInfo{ // Include one example custom agent "my-custom-agent": { Name: "my-custom-agent", Command: "my-agent-cli", Args: []string{"--autonomous", "--no-confirm"}, SessionIDEnv: "MY_AGENT_SESSION_ID", ResumeFlag: "--resume", ResumeStyle: "flag", NonInteractive: &NonInteractiveConfig{ PromptFlag: "-m", OutputFlag: "--json", }, }, }, } } // ResetRegistryForTesting clears all registry state. // This is intended for use in tests only to ensure test isolation. func ResetRegistryForTesting() { registryMu.Lock() defer registryMu.Unlock() globalRegistry = nil loadedPaths = make(map[string]bool) registryInitialized = false }