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:
george
2026-01-08 22:56:37 -08:00
committed by Steve Yegge
33 changed files with 850 additions and 176 deletions
+31 -19
View File
@@ -7,6 +7,7 @@ import (
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
@@ -73,7 +74,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
return nil
}
// Resolve account for Claude config
// Resolve account for runtime config
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
@@ -87,6 +88,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
fmt.Printf("Using account: %s\n", accountHandle)
}
runtimeConfig := config.LoadRuntimeConfig(r.Path)
_ = runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig)
// Check if session exists
t := tmux.NewTmux()
sessionID := crewSessionName(r.Name, name)
@@ -95,15 +99,15 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
return fmt.Errorf("checking session: %w", err)
}
// Before creating a new session, check if there's already a Claude session
// Before creating a new session, check if there's already a runtime session
// running in this crew's directory (might have been started manually or via
// a different mechanism)
if !hasSession {
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, true)
existingSessions, err := t.FindSessionByWorkDir(worker.ClonePath, runtimeConfig.Tmux.ProcessNames)
if err == nil && len(existingSessions) > 0 {
// Found an existing session with an agent running in this directory
// Found an existing session with runtime running in this directory
existingSession := existingSessions[0]
fmt.Printf("%s Found existing agent session '%s' in crew directory\n",
fmt.Printf("%s Found existing runtime session '%s' in crew directory\n",
style.Warning.Render("⚠"),
existingSession)
fmt.Printf(" Attaching to existing session instead of creating a new one\n")
@@ -137,9 +141,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
if claudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
// Set runtime config dir for account selection (non-fatal)
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir)
}
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
@@ -158,31 +162,35 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting pane ID: %w", err)
}
// Use respawn-pane to replace shell with Claude directly
// This gives cleaner lifecycle: Claude exits → session ends (no intermediate shell)
// Pass "gt prime" as initial prompt so Claude loads context immediately
// Use respawn-pane to replace shell with runtime directly
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
// Pass "gt prime" as initial prompt if supported
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
if err != nil {
return fmt.Errorf("building startup command: %w", err)
}
// Prepend config dir env if available
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
}
if err := t.RespawnPane(paneID, startupCmd); err != nil {
return fmt.Errorf("starting claude: %w", err)
return fmt.Errorf("starting runtime: %w", err)
}
fmt.Printf("%s Created session for %s/%s\n",
style.Bold.Render("✓"), r.Name, name)
} else {
// Session exists - check if Claude is still running
// Session exists - check if runtime is still running
// Uses both pane command check and UI marker detection to avoid
// restarting when user is in a subshell spawned from Claude
// restarting when user is in a subshell spawned from the runtime
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
if err != nil {
return fmt.Errorf("resolving agent: %w", err)
}
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
// Claude has exited, restart it using respawn-pane
fmt.Printf("Claude exited, restarting...\n")
// Runtime has exited, restart it using respawn-pane
fmt.Printf("Runtime exited, restarting...\n")
// Get pane ID for respawn
paneID, err := t.GetPaneID(sessionID)
@@ -190,15 +198,19 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting pane ID: %w", err)
}
// Use respawn-pane to replace shell with Claude directly
// Pass "gt prime" as initial prompt so Claude loads context immediately
// Use respawn-pane to replace shell with runtime directly
// Pass "gt prime" as initial prompt if supported
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, "gt prime", crewAgentOverride)
if err != nil {
return fmt.Errorf("building startup command: %w", err)
}
// Prepend config dir env if available
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
startupCmd = config.PrependEnv(startupCmd, map[string]string{runtimeConfig.Session.ConfigDirEnv: claudeConfigDir})
}
if err := t.RespawnPane(paneID, startupCmd); err != nil {
return fmt.Errorf("restarting claude: %w", err)
return fmt.Errorf("restarting runtime: %w", err)
}
}
}
+24 -1
View File
@@ -137,7 +137,7 @@ func detectCrewFromCwd() (*crewDetection, error) {
}, nil
}
// isShellCommand checks if the command is a shell (meaning Claude has exited).
// isShellCommand checks if the command is a shell (meaning the runtime has exited).
func isShellCommand(cmd string) bool {
shells := constants.SupportedShells
for _, shell := range shells {
@@ -170,6 +170,29 @@ func execAgent(cfg *config.RuntimeConfig, prompt string) error {
return syscall.Exec(agentPath, args, os.Environ())
}
// execRuntime execs the runtime CLI, replacing the current process.
// Used when we're already in the target session and just need to start the runtime.
// If prompt is provided, it's passed according to the runtime's prompt mode.
func execRuntime(prompt, rigPath, configDir string) error {
runtimeConfig := config.LoadRuntimeConfig(rigPath)
args := runtimeConfig.BuildArgsWithPrompt(prompt)
if len(args) == 0 {
return fmt.Errorf("runtime command not configured")
}
binPath, err := exec.LookPath(args[0])
if err != nil {
return fmt.Errorf("runtime command not found: %w", err)
}
env := os.Environ()
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && configDir != "" {
env = append(env, fmt.Sprintf("%s=%s", runtimeConfig.Session.ConfigDirEnv, configDir))
}
return syscall.Exec(binPath, args, env)
}
// isInTmuxSession checks if we're currently inside the target tmux session.
func isInTmuxSession(targetSession string) bool {
// TMUX env var format: /tmp/tmux-501/default,12345,0
+9 -8
View File
@@ -13,6 +13,7 @@ import (
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
@@ -163,7 +164,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
} else {
// Default: CLOSE the agent bead (preserves CV history)
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
@@ -236,9 +237,9 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
// Use manager's Start() with refresh options
err = crewMgr.Start(name, crew.StartOptions{
KillExisting: true, // Kill old session if running
Topic: "refresh", // Startup nudge topic
Interactive: true, // No --dangerously-skip-permissions
KillExisting: true, // Kill old session if running
Topic: "refresh", // Startup nudge topic
Interactive: true, // No --dangerously-skip-permissions
AgentOverride: crewAgentOverride,
})
if err != nil {
@@ -356,8 +357,8 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
// Use manager's Start() with restart options
// Start() will create workspace if needed (idempotent)
err = crewMgr.Start(name, crew.StartOptions{
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
AgentOverride: crewAgentOverride,
})
if err != nil {
@@ -436,8 +437,8 @@ func runCrewRestartAll() error {
// Use manager's Start() with restart options
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
KillExisting: true, // Kill old session if running
Topic: "restart", // Startup nudge topic
AgentOverride: crewAgentOverride,
})
if err != nil {
+5 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
@@ -112,7 +113,7 @@ var deaconTriggerPendingCmd = &cobra.Command{
BOOTSTRAP MODE ONLY - Uses regex detection (ZFC violation acceptable).
This command uses WaitForClaudeReady (regex) to detect when Claude is ready.
This command uses WaitForRuntimeReady (regex) to detect when the runtime is ready.
This is appropriate for daemon bootstrap when no AI is available.
In steady-state, the Deacon should use AI-based observation instead:
@@ -383,6 +384,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
}
time.Sleep(constants.ShutdownNotifyDelay)
runtimeConfig := config.LoadRuntimeConfig("")
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: "deacon",
+7 -3
View File
@@ -357,9 +357,13 @@ func buildRestartCommand(sessionName string) (string, error) {
// Build environment exports - role vars first, then Claude vars
var exports []string
if gtRole != "" {
exports = append(exports, fmt.Sprintf("GT_ROLE=%s", gtRole))
exports = append(exports, fmt.Sprintf("BD_ACTOR=%s", gtRole))
exports = append(exports, fmt.Sprintf("GIT_AUTHOR_NAME=%s", gtRole))
runtimeConfig := config.LoadRuntimeConfig("")
exports = append(exports, "GT_ROLE="+gtRole)
exports = append(exports, "BD_ACTOR="+gtRole)
exports = append(exports, "GIT_AUTHOR_NAME="+gtRole)
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
exports = append(exports, "GT_SESSION_ID_ENV="+runtimeConfig.Session.SessionIDEnv)
}
}
// Add Claude-related env vars from current environment
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
)
@@ -172,7 +173,7 @@ func runHook(_ *cobra.Command, args []string) error {
// Close completed molecule bead (use bd close --force for pinned)
closeArgs := []string{"close", existing.ID, "--force",
"--reason=Auto-replaced by gt hook (molecule complete)"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
+12 -12
View File
@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -89,7 +90,6 @@ Examples:
RunE: runPolecatRemove,
}
var polecatSyncCmd = &cobra.Command{
Use: "sync <rig>/<polecat>",
Short: "Sync beads for a polecat",
@@ -129,15 +129,15 @@ Examples:
}
var (
polecatSyncAll bool
polecatSyncFromMain bool
polecatStatusJSON bool
polecatGitStateJSON bool
polecatGCDryRun bool
polecatNukeAll bool
polecatNukeDryRun bool
polecatNukeForce bool
polecatCheckRecoveryJSON bool
polecatSyncAll bool
polecatSyncFromMain bool
polecatStatusJSON bool
polecatGitStateJSON bool
polecatGCDryRun bool
polecatNukeAll bool
polecatNukeDryRun bool
polecatNukeForce bool
polecatCheckRecoveryJSON bool
)
var polecatGCCmd = &cobra.Command{
@@ -975,7 +975,7 @@ type RecoveryStatus struct {
NeedsRecovery bool `json:"needs_recovery"`
Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY
Branch string `json:"branch,omitempty"`
Issue string `json:"issue,omitempty"`
Issue string `json:"issue,omitempty"`
}
func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error {
@@ -1477,7 +1477,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
// Step 5: Close agent bead (if exists)
agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName)
closeArgs := []string{"close", agentBeadID, "--reason=nuked"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
+2 -2
View File
@@ -139,7 +139,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
}, nil
}
// Resolve account for Claude config
// Resolve account for runtime config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
if err != nil {
@@ -158,7 +158,7 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
if !running {
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := polecat.SessionStartOptions{
ClaudeConfigDir: claudeConfigDir,
RuntimeConfigDir: claudeConfigDir,
}
if opts.Agent != "" {
cmd, err := config.BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, r.Path, "", opts.Agent)
+5 -9
View File
@@ -21,6 +21,7 @@ import (
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/lock"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
@@ -1503,22 +1504,17 @@ func outputSessionMetadata(ctx RoleContext) {
// resolveSessionIDForPrime finds the session ID from available sources.
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
func resolveSessionIDForPrime(actor string) string {
// 1. GT_SESSION_ID (new canonical)
if id := os.Getenv("GT_SESSION_ID"); id != "" {
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
if id := runtime.SessionIDFromEnv(); id != "" {
return id
}
// 2. CLAUDE_SESSION_ID (legacy/Claude Code)
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
return id
}
// 3. Persisted session file (from gt prime --hook)
// 2. Persisted session file (from gt prime --hook)
if id := ReadPersistedSessionID(); id != "" {
return id
}
// 4. Fallback to generated identifier
// 3. Fallback to generated identifier
return fmt.Sprintf("%s-%d", actor, os.Getpid())
}
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/swarm"
"github.com/steveyegge/gastown/internal/tmux"
@@ -808,7 +809,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
// Close the swarm epic in beads
closeArgs := []string{"close", swarmID, "--reason", "Swarm landed to main"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
@@ -867,7 +868,7 @@ func runSwarmCancel(cmd *cobra.Command, args []string) error {
// Close the swarm epic in beads with canceled reason
closeArgs := []string{"close", swarmID, "--reason", "Swarm canceled"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/formula"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -322,7 +323,7 @@ func runSynthesisClose(cmd *cobra.Command, args []string) error {
// Close the convoy
closeArgs := []string{"close", convoyID, "--reason=synthesis complete"}
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
closeArgs = append(closeArgs, "--session="+sessionID)
}
closeCmd := exec.Command("bd", closeArgs...)