Add AgentOpenCode as a first-class built-in agent preset, similar to
Claude, Gemini, Codex, Cursor, Auggie, and AMP.
OpenCode preset configuration:
- Command: "opencode"
- Args: [] (uses Env for YOLO mode, no CLI flags needed)
- Env: OPENCODE_PERMISSION='{"*":"allow"}' for auto-approve
- ProcessNames: ["opencode", "node"] (runs as Node.js)
- SupportsHooks: true (uses .opencode/plugin/gastown.js)
- NonInteractive: run subcommand with --format json
Runtime defaults for opencode provider:
- ready_delay_ms: 8000 (delay-based detection for TUI)
- process_names: [opencode, node]
- instructions_file: AGENTS.md
This allows users to simply configure:
role_agents:
refinery: "opencode"
Instead of manually configuring agents.json and runtime settings.
Test coverage:
- TestOpenCodeAgentPreset: comprehensive preset validation
- TestOpenCodeProviderDefaults: runtime config defaults
- TestOpenCodeRuntimeConfigFromPreset: Env copying
- TestIsKnownPreset: includes opencode
- TestGetAgentPresetByName: opencode returns preset
Templates added:
- templates/agents/opencode.json.tmpl: agent config template
- templates/agents/opencode-models.json: model delay presets
Co-authored-by: Avyukth <subhrajit.makur@hotmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
522 lines
16 KiB
Go
522 lines
16 KiB
Go
// 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"
|
|
// AgentOpenCode is OpenCode multi-model CLI.
|
|
AgentOpenCode AgentPreset = "opencode"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// Env are environment variables to set when starting the agent.
|
|
// These are merged with the standard GT_* variables.
|
|
// Used for agent-specific configuration like OPENCODE_PERMISSION.
|
|
Env map[string]string `json:"env,omitempty"`
|
|
|
|
// 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 <id> argument
|
|
// "subcommand" - pass as 'codex resume <id>'
|
|
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 <threadId>'
|
|
SupportsHooks: false,
|
|
SupportsForkSession: false,
|
|
},
|
|
AgentOpenCode: {
|
|
Name: AgentOpenCode,
|
|
Command: "opencode",
|
|
Args: []string{}, // No CLI flags needed, YOLO via OPENCODE_PERMISSION env
|
|
Env: map[string]string{
|
|
// Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)
|
|
"OPENCODE_PERMISSION": `{"*":"allow"}`,
|
|
},
|
|
ProcessNames: []string{"opencode", "node"}, // Runs as Node.js
|
|
SessionIDEnv: "", // OpenCode manages sessions internally
|
|
ResumeFlag: "", // No resume support yet
|
|
ResumeStyle: "",
|
|
SupportsHooks: true, // Uses .opencode/plugin/gastown.js
|
|
SupportsForkSession: false,
|
|
NonInteractive: &NonInteractiveConfig{
|
|
Subcommand: "run",
|
|
OutputFlag: "--format json",
|
|
},
|
|
},
|
|
}
|
|
|
|
// 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 <rig>/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/Env; 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()
|
|
}
|
|
|
|
// Copy Env map to avoid mutation
|
|
var envCopy map[string]string
|
|
if len(info.Env) > 0 {
|
|
envCopy = make(map[string]string, len(info.Env))
|
|
for k, v := range info.Env {
|
|
envCopy[k] = v
|
|
}
|
|
}
|
|
|
|
rc := &RuntimeConfig{
|
|
Command: info.Command,
|
|
Args: append([]string(nil), info.Args...), // Copy to avoid mutation
|
|
Env: envCopy,
|
|
}
|
|
|
|
// Resolve command path for claude preset (handles alias installations)
|
|
// Uses resolveClaudePath() from types.go which finds ~/.claude/local/claude
|
|
if preset == AgentClaude && rc.Command == "claude" {
|
|
rc.Command = resolveClaudePath()
|
|
}
|
|
|
|
return rc
|
|
}
|
|
|
|
// 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 <session_id> --yolo"
|
|
return info.Command + " " + info.ResumeFlag + " " + sessionID + " " + strings.Join(args, " ")
|
|
case "flag":
|
|
fallthrough
|
|
default:
|
|
// e.g., "claude --dangerously-skip-permissions --resume <session_id>"
|
|
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
|
|
}
|