feat: Add Codex and OpenCode runtime backend support (#281)
Adds support for alternative AI runtime backends (Codex, OpenCode) alongside the default Claude backend through a runtime abstraction layer. - internal/runtime/runtime.go - Runtime-agnostic helper functions - Extended RuntimeConfig with provider-specific settings - internal/opencode/ for OpenCode plugin support - Updated session managers to use runtime abstraction - Removed unused ensureXxxSession functions - Fixed daemon.go indentation, updated terminology to runtime Backward compatible: Claude remains default runtime. Co-Authored-By: Ben Kraus <ben@cinematicsoftware.com> Co-Authored-By: Cameron Palmer <cameronmpalmer@users.noreply.github.com>
This commit is contained in:
@@ -726,15 +726,7 @@ func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
|
||||
if settings.Runtime == nil {
|
||||
return DefaultRuntimeConfig()
|
||||
}
|
||||
// Fill in defaults for empty fields
|
||||
rc := settings.Runtime
|
||||
if rc.Command == "" {
|
||||
rc.Command = "claude"
|
||||
}
|
||||
if rc.Args == nil {
|
||||
rc.Args = []string{"--dangerously-skip-permissions"}
|
||||
}
|
||||
return rc
|
||||
return normalizeRuntimeConfig(settings.Runtime)
|
||||
}
|
||||
|
||||
// TownSettingsPath returns the path to town settings file.
|
||||
@@ -1080,14 +1072,22 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
|
||||
}
|
||||
}
|
||||
|
||||
// Copy env vars to avoid mutating caller map
|
||||
resolvedEnv := make(map[string]string, len(envVars)+2)
|
||||
for k, v := range envVars {
|
||||
resolvedEnv[k] = v
|
||||
}
|
||||
// Add GT_ROOT so agents can find town-level resources (formulas, etc.)
|
||||
if townRoot != "" {
|
||||
envVars["GT_ROOT"] = townRoot
|
||||
resolvedEnv["GT_ROOT"] = townRoot
|
||||
}
|
||||
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
|
||||
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
|
||||
}
|
||||
|
||||
// Build environment export prefix
|
||||
var exports []string
|
||||
for k, v := range envVars {
|
||||
for k, v := range resolvedEnv {
|
||||
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
@@ -1109,6 +1109,21 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
|
||||
return cmd
|
||||
}
|
||||
|
||||
// PrependEnv prepends export statements to a command string.
|
||||
func PrependEnv(command string, envVars map[string]string) string {
|
||||
if len(envVars) == 0 {
|
||||
return command
|
||||
}
|
||||
|
||||
var exports []string
|
||||
for k, v := range envVars {
|
||||
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
sort.Strings(exports)
|
||||
return "export " + strings.Join(exports, " ") + " && " + command
|
||||
}
|
||||
|
||||
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
|
||||
// but uses agentOverride if non-empty.
|
||||
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
|
||||
|
||||
@@ -778,12 +778,18 @@ func TestMessagingConfigPath(t *testing.T) {
|
||||
|
||||
func TestRuntimeConfigDefaults(t *testing.T) {
|
||||
rc := DefaultRuntimeConfig()
|
||||
if rc.Provider != "claude" {
|
||||
t.Errorf("Provider = %q, want %q", rc.Provider, "claude")
|
||||
}
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||
}
|
||||
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
|
||||
t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args)
|
||||
}
|
||||
if rc.Session == nil || rc.Session.SessionIDEnv != "CLAUDE_SESSION_ID" {
|
||||
t.Errorf("SessionIDEnv = %q, want %q", rc.Session.SessionIDEnv, "CLAUDE_SESSION_ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfigBuildCommand(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -220,6 +221,10 @@ type CrewConfig struct {
|
||||
// This allows switching between different LLM backends (claude, aider, etc.)
|
||||
// without modifying startup code.
|
||||
type RuntimeConfig struct {
|
||||
// Provider selects runtime-specific defaults and integration behavior.
|
||||
// Known values: "claude", "codex", "generic". Default: "claude".
|
||||
Provider string `json:"provider,omitempty"`
|
||||
|
||||
// Command is the CLI command to invoke (e.g., "claude", "aider").
|
||||
// Default: "claude"
|
||||
Command string `json:"command,omitempty"`
|
||||
@@ -233,33 +238,78 @@ type RuntimeConfig struct {
|
||||
// For claude, this is passed as the prompt argument.
|
||||
// Empty by default (hooks handle context).
|
||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||
|
||||
// PromptMode controls how prompts are passed to the runtime.
|
||||
// Supported values: "arg" (append prompt arg), "none" (ignore prompt).
|
||||
// Default: "arg" for claude/generic, "none" for codex.
|
||||
PromptMode string `json:"prompt_mode,omitempty"`
|
||||
|
||||
// Session config controls environment integration for runtime session IDs.
|
||||
Session *RuntimeSessionConfig `json:"session,omitempty"`
|
||||
|
||||
// Hooks config controls runtime hook installation (if supported).
|
||||
Hooks *RuntimeHooksConfig `json:"hooks,omitempty"`
|
||||
|
||||
// Tmux config controls process detection and readiness heuristics.
|
||||
Tmux *RuntimeTmuxConfig `json:"tmux,omitempty"`
|
||||
|
||||
// Instructions controls the per-workspace instruction file name.
|
||||
Instructions *RuntimeInstructionsConfig `json:"instructions,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeSessionConfig configures how Gas Town discovers runtime session IDs.
|
||||
type RuntimeSessionConfig struct {
|
||||
// SessionIDEnv is the environment variable set by the runtime to identify a session.
|
||||
// Default: "CLAUDE_SESSION_ID" for claude, empty for codex/generic.
|
||||
SessionIDEnv string `json:"session_id_env,omitempty"`
|
||||
|
||||
// ConfigDirEnv is the environment variable that selects a runtime account/config dir.
|
||||
// Default: "CLAUDE_CONFIG_DIR" for claude, empty for codex/generic.
|
||||
ConfigDirEnv string `json:"config_dir_env,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeHooksConfig configures runtime hook installation.
|
||||
type RuntimeHooksConfig struct {
|
||||
// Provider controls which hook templates to install: "claude", "opencode", or "none".
|
||||
Provider string `json:"provider,omitempty"`
|
||||
|
||||
// Dir is the settings directory (e.g., ".claude").
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// SettingsFile is the settings file name (e.g., "settings.json").
|
||||
SettingsFile string `json:"settings_file,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeTmuxConfig controls tmux heuristics for detecting runtime readiness.
|
||||
type RuntimeTmuxConfig struct {
|
||||
// ProcessNames are tmux pane commands that indicate the runtime is running.
|
||||
ProcessNames []string `json:"process_names,omitempty"`
|
||||
|
||||
// ReadyPromptPrefix is the prompt prefix to detect readiness (e.g., "> ").
|
||||
ReadyPromptPrefix string `json:"ready_prompt_prefix,omitempty"`
|
||||
|
||||
// ReadyDelayMs is a fixed delay used when prompt detection is unavailable.
|
||||
ReadyDelayMs int `json:"ready_delay_ms,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeInstructionsConfig controls the name of the role instruction file.
|
||||
type RuntimeInstructionsConfig struct {
|
||||
// File is the instruction filename (e.g., "CLAUDE.md", "AGENTS.md").
|
||||
File string `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultRuntimeConfig returns a RuntimeConfig with sensible defaults.
|
||||
func DefaultRuntimeConfig() *RuntimeConfig {
|
||||
return &RuntimeConfig{
|
||||
Command: "claude",
|
||||
Args: []string{"--dangerously-skip-permissions"},
|
||||
}
|
||||
return normalizeRuntimeConfig(&RuntimeConfig{Provider: "claude"})
|
||||
}
|
||||
|
||||
// BuildCommand returns the full command line string.
|
||||
// For use with tmux SendKeys.
|
||||
func (rc *RuntimeConfig) BuildCommand() string {
|
||||
if rc == nil {
|
||||
return DefaultRuntimeConfig().BuildCommand()
|
||||
}
|
||||
resolved := normalizeRuntimeConfig(rc)
|
||||
|
||||
cmd := rc.Command
|
||||
if cmd == "" {
|
||||
cmd = "claude"
|
||||
}
|
||||
|
||||
// Build args
|
||||
args := rc.Args
|
||||
if args == nil {
|
||||
args = []string{"--dangerously-skip-permissions"}
|
||||
}
|
||||
cmd := resolved.Command
|
||||
args := resolved.Args
|
||||
|
||||
// Combine command and args
|
||||
if len(args) > 0 {
|
||||
@@ -272,15 +322,16 @@ func (rc *RuntimeConfig) BuildCommand() string {
|
||||
// If the config has an InitialPrompt, it's appended as a quoted argument.
|
||||
// If prompt is provided, it overrides the config's InitialPrompt.
|
||||
func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
||||
base := rc.BuildCommand()
|
||||
resolved := normalizeRuntimeConfig(rc)
|
||||
base := resolved.BuildCommand()
|
||||
|
||||
// Use provided prompt or fall back to config
|
||||
p := prompt
|
||||
if p == "" && rc != nil {
|
||||
p = rc.InitialPrompt
|
||||
if p == "" {
|
||||
p = resolved.InitialPrompt
|
||||
}
|
||||
|
||||
if p == "" {
|
||||
if p == "" || resolved.PromptMode == "none" {
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -288,6 +339,216 @@ func (rc *RuntimeConfig) BuildCommandWithPrompt(prompt string) string {
|
||||
return base + " " + quoteForShell(p)
|
||||
}
|
||||
|
||||
// BuildArgsWithPrompt returns the runtime command and args suitable for exec.
|
||||
func (rc *RuntimeConfig) BuildArgsWithPrompt(prompt string) []string {
|
||||
resolved := normalizeRuntimeConfig(rc)
|
||||
args := append([]string{resolved.Command}, resolved.Args...)
|
||||
|
||||
p := prompt
|
||||
if p == "" {
|
||||
p = resolved.InitialPrompt
|
||||
}
|
||||
|
||||
if p != "" && resolved.PromptMode != "none" {
|
||||
args = append(args, p)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func normalizeRuntimeConfig(rc *RuntimeConfig) *RuntimeConfig {
|
||||
if rc == nil {
|
||||
rc = &RuntimeConfig{}
|
||||
}
|
||||
|
||||
if rc.Provider == "" {
|
||||
rc.Provider = "claude"
|
||||
}
|
||||
|
||||
if rc.Command == "" {
|
||||
rc.Command = defaultRuntimeCommand(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Args == nil {
|
||||
rc.Args = defaultRuntimeArgs(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.PromptMode == "" {
|
||||
rc.PromptMode = defaultPromptMode(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Session == nil {
|
||||
rc.Session = &RuntimeSessionConfig{}
|
||||
}
|
||||
|
||||
if rc.Session.SessionIDEnv == "" {
|
||||
rc.Session.SessionIDEnv = defaultSessionIDEnv(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Session.ConfigDirEnv == "" {
|
||||
rc.Session.ConfigDirEnv = defaultConfigDirEnv(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Hooks == nil {
|
||||
rc.Hooks = &RuntimeHooksConfig{}
|
||||
}
|
||||
|
||||
if rc.Hooks.Provider == "" {
|
||||
rc.Hooks.Provider = defaultHooksProvider(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Hooks.Dir == "" {
|
||||
rc.Hooks.Dir = defaultHooksDir(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Hooks.SettingsFile == "" {
|
||||
rc.Hooks.SettingsFile = defaultHooksFile(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Tmux == nil {
|
||||
rc.Tmux = &RuntimeTmuxConfig{}
|
||||
}
|
||||
|
||||
if rc.Tmux.ProcessNames == nil {
|
||||
rc.Tmux.ProcessNames = defaultProcessNames(rc.Provider, rc.Command)
|
||||
}
|
||||
|
||||
if rc.Tmux.ReadyPromptPrefix == "" {
|
||||
rc.Tmux.ReadyPromptPrefix = defaultReadyPromptPrefix(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Tmux.ReadyDelayMs == 0 {
|
||||
rc.Tmux.ReadyDelayMs = defaultReadyDelayMs(rc.Provider)
|
||||
}
|
||||
|
||||
if rc.Instructions == nil {
|
||||
rc.Instructions = &RuntimeInstructionsConfig{}
|
||||
}
|
||||
|
||||
if rc.Instructions.File == "" {
|
||||
rc.Instructions.File = defaultInstructionsFile(rc.Provider)
|
||||
}
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
func defaultRuntimeCommand(provider string) string {
|
||||
switch provider {
|
||||
case "codex":
|
||||
return "codex"
|
||||
case "opencode":
|
||||
return "opencode"
|
||||
case "generic":
|
||||
return ""
|
||||
default:
|
||||
return "claude"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRuntimeArgs(provider string) []string {
|
||||
switch provider {
|
||||
case "claude":
|
||||
return []string{"--dangerously-skip-permissions"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPromptMode(provider string) string {
|
||||
switch provider {
|
||||
case "codex":
|
||||
return "none"
|
||||
case "opencode":
|
||||
return "none"
|
||||
default:
|
||||
return "arg"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSessionIDEnv(provider string) string {
|
||||
if provider == "claude" {
|
||||
return "CLAUDE_SESSION_ID"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultConfigDirEnv(provider string) string {
|
||||
if provider == "claude" {
|
||||
return "CLAUDE_CONFIG_DIR"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultHooksProvider(provider string) string {
|
||||
switch provider {
|
||||
case "claude":
|
||||
return "claude"
|
||||
case "opencode":
|
||||
return "opencode"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHooksDir(provider string) string {
|
||||
switch provider {
|
||||
case "claude":
|
||||
return ".claude"
|
||||
case "opencode":
|
||||
return ".opencode/plugin"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func defaultHooksFile(provider string) string {
|
||||
switch provider {
|
||||
case "claude":
|
||||
return "settings.json"
|
||||
case "opencode":
|
||||
return "gastown.js"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func defaultProcessNames(provider, command string) []string {
|
||||
if provider == "claude" {
|
||||
return []string{"node"}
|
||||
}
|
||||
if command != "" {
|
||||
return []string{filepath.Base(command)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultReadyPromptPrefix(provider string) string {
|
||||
if provider == "claude" {
|
||||
return "> "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultReadyDelayMs(provider string) int {
|
||||
if provider == "claude" {
|
||||
return 10000
|
||||
}
|
||||
if provider == "codex" {
|
||||
return 3000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func defaultInstructionsFile(provider string) string {
|
||||
if provider == "codex" {
|
||||
return "AGENTS.md"
|
||||
}
|
||||
if provider == "opencode" {
|
||||
return "AGENTS.md"
|
||||
}
|
||||
return "CLAUDE.md"
|
||||
}
|
||||
|
||||
// quoteForShell quotes a string for safe shell usage.
|
||||
func quoteForShell(s string) string {
|
||||
// Simple quoting: wrap in double quotes, escape internal quotes
|
||||
|
||||
Reference in New Issue
Block a user