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

View File

@@ -85,7 +85,8 @@ Git-backed issue tracking system that stores work state as structured data.
- **Git 2.25+** - for worktree support
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
- **tmux 3.0+** - recommended for full experience
- **Claude Code CLI** - [claude.ai/code](https://claude.ai/code)
- **Claude Code CLI** (default runtime) - [claude.ai/code](https://claude.ai/code)
- **Codex CLI** (optional runtime) - [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli)
### Setup
@@ -180,6 +181,18 @@ gt convoy create "Auth System" issue-101 issue-102 --notify
gt convoy list
```
### Minimal Mode (No Tmux)
Run individual runtime instances manually. Gas Town just tracks state.
```bash
gt convoy create "Fix bugs" issue-123 # Create convoy (sling auto-creates if skipped)
gt sling issue-123 myproject # Assign to worker
claude --resume # Agent reads mail, runs work (Claude)
# or: codex # Start Codex in the workspace
gt convoy list # Check progress
```
### Beads Formula Workflow
**Best for:** Predefined, repeatable processes
@@ -258,6 +271,30 @@ gt sling bug-101 myproject/my-agent
gt convoy show
```
## Runtime Configuration
Gas Town supports multiple AI coding runtimes. Per-rig runtime settings are in `settings/config.json`.
```json
{
"runtime": {
"provider": "codex",
"command": "codex",
"args": [],
"prompt_mode": "none"
}
}
```
**Notes:**
- Claude uses hooks in `.claude/settings.json` for mail injection and startup.
- For Codex, set `project_doc_fallback_filenames = ["CLAUDE.md"]` in
`~/.codex/config.toml` so role instructions are picked up.
- For runtimes without hooks (e.g., Codex), Gas Town sends a startup fallback
after the session is ready: `gt prime`, optional `gt mail check --inject`
for autonomous roles, and `gt nudge deacon session-started`.
## Key Commands
### Workspace Management
@@ -314,6 +351,10 @@ bd mol pour <formula> # Create trackable instance
bd mol list # List active instances
```
## Cooking Formulas
Gas Town includes built-in formulas for common workflows. See `.beads/formulas/` for available recipes.
## Dashboard
Gas Town includes a web dashboard for monitoring:

View File

@@ -17,7 +17,9 @@ Complete setup guide for Gas Town multi-agent orchestrator.
| Tool | Version | Check | Install |
|------|---------|-------|---------|
| **tmux** | 3.0+ | `tmux -V` | See below |
| **Claude Code** | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
| **Claude Code** (default) | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) |
| **Codex CLI** (optional) | latest | `codex --version` | See [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) |
| **OpenCode CLI** (optional) | latest | `opencode --version` | See [opencode.ai](https://opencode.ai) |
## Installing Prerequisites
@@ -159,16 +161,17 @@ Gas Town supports two operational modes:
### Minimal Mode (No Daemon)
Run individual Claude Code instances manually. Gas Town only tracks state.
Run individual runtime instances manually. Gas Town only tracks state.
```bash
# Create and assign work
gt convoy create "Fix bugs" issue-123
gt sling issue-123 myproject
# Run Claude manually
# Run runtime manually
cd ~/gt/myproject/polecats/<worker>
claude --resume
claude --resume # Claude Code
# or: codex # Codex CLI
# Check progress
gt convoy list

View File

@@ -10,6 +10,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/runtime"
)
// Common errors
@@ -771,7 +773,7 @@ func (b *Beads) Update(id string, opts UpdateOptions) error {
}
// Close closes one or more issues.
// If CLAUDE_SESSION_ID is set in the environment, it is passed to bd close
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
func (b *Beads) Close(ids ...string) error {
if len(ids) == 0 {
@@ -781,7 +783,7 @@ func (b *Beads) Close(ids ...string) error {
args := append([]string{"close"}, ids...)
// Pass session ID for work attribution if available
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
args = append(args, "--session="+sessionID)
}
@@ -790,7 +792,7 @@ func (b *Beads) Close(ids ...string) error {
}
// CloseWithReason closes one or more issues with a reason.
// If CLAUDE_SESSION_ID is set in the environment, it is passed to bd close
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
if len(ids) == 0 {
@@ -801,7 +803,7 @@ func (b *Beads) CloseWithReason(reason string, ids ...string) error {
args = append(args, "--reason="+reason)
// Pass session ID for work attribution if available
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
args = append(args, "--session="+sessionID)
}

View File

@@ -11,6 +11,8 @@ import (
"path/filepath"
"strings"
"time"
"github.com/steveyegge/gastown/internal/runtime"
)
// Filename is the checkpoint file name within the polecat directory.
@@ -84,7 +86,7 @@ func Write(polecatDir string, cp *Checkpoint) error {
// Set session ID from environment if available
if cp.SessionID == "" {
cp.SessionID = os.Getenv("CLAUDE_SESSION_ID")
cp.SessionID = runtime.SessionIDFromEnv()
if cp.SessionID == "" {
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
}

View File

@@ -38,17 +38,24 @@ func RoleTypeFor(role string) RoleType {
// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory,
// so our settings.json is the only one Claude Code sees.
func EnsureSettings(workDir string, roleType RoleType) error {
claudeDir := filepath.Join(workDir, ".claude")
settingsPath := filepath.Join(claudeDir, "settings.json")
return EnsureSettingsAt(workDir, roleType, ".claude", "settings.json")
}
// EnsureSettingsAt ensures a settings file exists at a custom directory/file.
// If the file doesn't exist, it copies the appropriate template based on role type.
// If the file already exists, it's left unchanged.
func EnsureSettingsAt(workDir string, roleType RoleType, settingsDir, settingsFile string) error {
claudeDir := filepath.Join(workDir, settingsDir)
settingsPath := filepath.Join(claudeDir, settingsFile)
// If settings already exist, don't overwrite
if _, err := os.Stat(settingsPath); err == nil {
return nil
}
// Create .claude directory if needed
// Create settings directory if needed
if err := os.MkdirAll(claudeDir, 0755); err != nil {
return fmt.Errorf("creating .claude directory: %w", err)
return fmt.Errorf("creating settings directory: %w", err)
}
// Select template based on role type
@@ -78,3 +85,8 @@ func EnsureSettings(workDir string, roleType RoleType) error {
func EnsureSettingsForRole(workDir, role string) error {
return EnsureSettings(workDir, RoleTypeFor(role))
}
// EnsureSettingsForRoleAt is a convenience function that combines RoleTypeFor and EnsureSettingsAt.
func EnsureSettingsForRoleAt(workDir, role, settingsDir, settingsFile string) error {
return EnsureSettingsAt(workDir, RoleTypeFor(role), settingsDir, settingsFile)
}

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)
}
}
}

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

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...)

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",

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

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...)

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",
@@ -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...)

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)

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())
}

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...)

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...)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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

View File

@@ -188,7 +188,7 @@ func (d *Daemon) heartbeat(state *State) {
// 6. Trigger pending polecat spawns (bootstrap mode - ZFC violation acceptable)
// This ensures polecats get nudged even when Deacon isn't in a patrol cycle.
// Uses regex-based WaitForClaudeReady, which is acceptable for daemon bootstrap.
// Uses regex-based WaitForRuntimeReady, which is acceptable for daemon bootstrap.
d.triggerPendingSpawns()
// 7. Process lifecycle requests
@@ -498,7 +498,7 @@ func (d *Daemon) isRigOperational(rigName string) (bool, string) {
}
// triggerPendingSpawns polls pending polecat spawns and triggers those that are ready.
// This is bootstrap mode - uses regex-based WaitForClaudeReady which is acceptable
// This is bootstrap mode - uses regex-based WaitForRuntimeReady which is acceptable
// for daemon operations when no AI agent is guaranteed to be running.
// The timeout is short (2s) to avoid blocking the heartbeat.
func (d *Daemon) triggerPendingSpawns() {
@@ -517,7 +517,7 @@ func (d *Daemon) triggerPendingSpawns() {
d.logger.Printf("Found %d pending spawn(s), attempting to trigger...", len(pending))
// Trigger pending spawns (uses WaitForClaudeReady with short timeout)
// Trigger pending spawns (uses WaitForRuntimeReady with short timeout)
results, err := polecat.TriggerPendingSpawns(d.config.TownRoot, triggerTimeout)
if err != nil {
d.logger.Printf("Error triggering spawns: %v", err)

View File

@@ -466,6 +466,10 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
// Default command for all agents - use runtime config
defaultCmd := "exec " + config.GetRuntimeCommand(rigPath)
runtimeConfig := config.LoadRuntimeConfig(rigPath)
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": runtimeConfig.Session.SessionIDEnv})
}
// Polecats need environment variables set in the command
if parsed.RoleType == "polecat" {

View File

@@ -225,8 +225,8 @@ func (c *OrphanSessionCheck) isValidSession(sess string, validRigs []string, may
return true
}
// OrphanProcessCheck detects Claude/claude-code processes that are not
// running inside a tmux session. These may be user's personal Claude sessions
// OrphanProcessCheck detects runtime processes that are not
// running inside a tmux session. These may be user's personal sessions
// or legitimately orphaned processes from crashed Gas Town sessions.
// This check is informational only - it does not auto-fix since we cannot
// distinguish user sessions from orphaned Gas Town processes.
@@ -239,12 +239,12 @@ func NewOrphanProcessCheck() *OrphanProcessCheck {
return &OrphanProcessCheck{
BaseCheck: BaseCheck{
CheckName: "orphan-processes",
CheckDescription: "Detect Claude processes outside tmux",
CheckDescription: "Detect runtime processes outside tmux",
},
}
}
// Run checks for Claude processes running outside tmux.
// Run checks for runtime processes running outside tmux.
func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
// Get list of tmux session PIDs
tmuxPIDs, err := c.getTmuxSessionPIDs()
@@ -257,30 +257,30 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
}
}
// Find Claude processes
claudeProcs, err := c.findClaudeProcesses()
// Find runtime processes
runtimeProcs, err := c.findRuntimeProcesses()
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Could not list Claude processes",
Message: "Could not list runtime processes",
Details: []string{err.Error()},
}
}
if len(claudeProcs) == 0 {
if len(runtimeProcs) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No Claude processes found",
Message: "No runtime processes found",
}
}
// Check which Claude processes are outside tmux
// Check which runtime processes are outside tmux
var outsideTmux []processInfo
var insideTmux int
for _, proc := range claudeProcs {
for _, proc := range runtimeProcs {
if c.isOrphanProcess(proc, tmuxPIDs) {
outsideTmux = append(outsideTmux, proc)
} else {
@@ -292,12 +292,12 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d Claude processes are inside tmux", insideTmux),
Message: fmt.Sprintf("All %d runtime processes are inside tmux", insideTmux),
}
}
details := make([]string, 0, len(outsideTmux)+2)
details = append(details, "These may be your personal Claude sessions or orphaned Gas Town processes.")
details = append(details, "These may be your personal sessions or orphaned Gas Town processes.")
details = append(details, "Verify these are expected before manually killing any:")
for _, proc := range outsideTmux {
details = append(details, fmt.Sprintf(" PID %d: %s (parent: %d)", proc.pid, proc.cmd, proc.ppid))
@@ -306,7 +306,7 @@ func (c *OrphanProcessCheck) Run(ctx *CheckContext) *CheckResult {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("Found %d Claude process(es) running outside tmux", len(outsideTmux)),
Message: fmt.Sprintf("Found %d runtime process(es) running outside tmux", len(outsideTmux)),
Details: details,
}
}
@@ -358,21 +358,20 @@ func (c *OrphanProcessCheck) getTmuxSessionPIDs() (map[int]bool, error) { //noli
return pids, nil
}
// findClaudeProcesses finds all running claude/claude-code CLI processes.
// findRuntimeProcesses finds all running runtime CLI processes.
// Excludes Claude.app desktop application and its helpers.
func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
func (c *OrphanProcessCheck) findRuntimeProcesses() ([]processInfo, error) {
var procs []processInfo
// Use ps to find claude processes
// Look for both "claude" and "claude-code" in command
// Use ps to find runtime processes
out, err := exec.Command("ps", "-eo", "pid,ppid,comm").Output()
if err != nil {
return nil, err
}
// Regex to match claude CLI processes (not Claude.app)
// Match: "claude" or paths ending in "/claude"
claudePattern := regexp.MustCompile(`(?i)(^claude$|/claude$)`)
// Regex to match runtime CLI processes (not Claude.app)
// Match: "claude", "claude-code", or "codex" (or paths ending in those)
runtimePattern := regexp.MustCompile(`(?i)(^claude$|/claude$|^claude-code$|/claude-code$|^codex$|/codex$)`)
// Pattern to exclude Claude.app and related desktop processes
excludePattern := regexp.MustCompile(`(?i)(Claude\.app|claude-native|chrome-native)`)
@@ -383,7 +382,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
continue
}
// Check if command matches claude CLI
// Check if command matches runtime CLI
cmd := strings.Join(fields[2:], " ")
// Skip desktop app processes
@@ -391,8 +390,8 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
continue
}
// Only match CLI claude processes
if !claudePattern.MatchString(cmd) {
// Only match CLI runtime processes
if !runtimePattern.MatchString(cmd) {
continue
}
@@ -414,7 +413,7 @@ func (c *OrphanProcessCheck) findClaudeProcesses() ([]processInfo, error) {
return procs, nil
}
// isOrphanProcess checks if a Claude process is orphaned.
// isOrphanProcess checks if a runtime process is orphaned.
// A process is orphaned if its parent (or ancestor) is not a tmux session.
func (c *OrphanProcessCheck) isOrphanProcess(proc processInfo, tmuxPIDs map[int]bool) bool {
// Walk up the process tree looking for a tmux parent

View File

@@ -59,7 +59,7 @@ func TestOrphanProcessCheck_MessageContent(t *testing.T) {
// Verify the check description is correct
check := NewOrphanProcessCheck()
expectedDesc := "Detect Claude processes outside tmux"
expectedDesc := "Detect runtime processes outside tmux"
if check.Description() != expectedDesc {
t.Errorf("expected description %q, got %q", expectedDesc, check.Description())
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/runtime"
)
// timeNow is a function that returns the current time. It can be overridden in tests.
@@ -334,7 +335,7 @@ func (m *Mailbox) markReadBeads(id string) error {
func (m *Mailbox) closeInDir(id, beadsDir string) error {
args := []string{"close", id}
// Pass session ID for work attribution if available
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
args = append(args, "--session="+sessionID)
}

View File

@@ -0,0 +1,40 @@
// Package opencode provides OpenCode plugin management.
package opencode
import (
"embed"
"fmt"
"os"
"path/filepath"
)
//go:embed plugin/gastown.js
var pluginFS embed.FS
// EnsurePluginAt ensures the Gas Town OpenCode plugin exists.
// If the file already exists, it's left unchanged.
func EnsurePluginAt(workDir, pluginDir, pluginFile string) error {
if pluginDir == "" || pluginFile == "" {
return nil
}
pluginPath := filepath.Join(workDir, pluginDir, pluginFile)
if _, err := os.Stat(pluginPath); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil {
return fmt.Errorf("creating plugin directory: %w", err)
}
content, err := pluginFS.ReadFile("plugin/gastown.js")
if err != nil {
return fmt.Errorf("reading plugin template: %w", err)
}
if err := os.WriteFile(pluginPath, content, 0644); err != nil {
return fmt.Errorf("writing plugin: %w", err)
}
return nil
}

View File

@@ -0,0 +1,32 @@
// Gas Town OpenCode plugin: hooks SessionStart/Compaction via events.
export const GasTown = async ({ $, directory }) => {
const role = (process.env.GT_ROLE || "").toLowerCase();
const autonomousRoles = new Set(["polecat", "witness", "refinery", "deacon"]);
let didInit = false;
const run = async (cmd) => {
try {
await $`/bin/sh -lc ${cmd}`.cwd(directory);
} catch (err) {
console.error(`[gastown] ${cmd} failed`, err?.message || err);
}
};
const onSessionCreated = async () => {
if (didInit) return;
didInit = true;
await run("gt prime");
if (autonomousRoles.has(role)) {
await run("gt mail check --inject");
}
await run("gt nudge deacon session-started");
};
return {
event: async ({ event }) => {
if (event?.type === "session.created") {
await onSessionCreated();
}
},
};
};

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -195,15 +196,17 @@ func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResu
continue
}
// Check if Claude is ready (non-blocking poll)
err = t.WaitForClaudeReady(ps.Session, timeout)
// Check if runtime is ready (non-blocking poll)
rigPath := filepath.Join(townRoot, ps.Rig)
runtimeConfig := config.LoadRuntimeConfig(rigPath)
err = t.WaitForRuntimeReady(ps.Session, runtimeConfig, timeout)
if err != nil {
// Not ready yet - keep in pending
remaining = append(remaining, ps)
continue
}
// Claude is ready - send trigger
// Runtime is ready - send trigger
triggerMsg := "Begin."
if err := t.NudgeSession(ps.Session, triggerMsg); err != nil {
result.Error = fmt.Errorf("nudging session: %w", err)

View File

@@ -10,10 +10,10 @@ import (
"strings"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -59,9 +59,9 @@ type SessionStartOptions struct {
// Account specifies the account handle to use (overrides default).
Account string
// ClaudeConfigDir is resolved CLAUDE_CONFIG_DIR for the account.
// RuntimeConfigDir is resolved config directory for the runtime account.
// If set, this is injected as an environment variable.
ClaudeConfigDir string
RuntimeConfigDir string
}
// SessionInfo contains information about a running polecat session.
@@ -134,11 +134,13 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
workDir = m.polecatDir(polecat)
}
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
// Ensure runtime settings exist in polecats/ (not polecats/<name>/) so we don't
// write into the source repo. Runtime walks up the tree to find settings.
polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
if err := runtime.EnsureSettingsForRole(polecatsDir, "polecat", runtimeConfig); err != nil {
return fmt.Errorf("ensuring runtime settings: %w", err)
}
// Create session
@@ -150,9 +152,9 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name))
debugSession("SetEnvironment GT_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
if opts.ClaudeConfigDir != "" {
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
// Set runtime config dir for account selection (non-fatal)
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
debugSession("SetEnvironment "+runtimeConfig.Session.ConfigDirEnv, m.tmux.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, opts.RuntimeConfigDir))
}
// Set beads environment for worktree polecats (non-fatal)
@@ -183,6 +185,10 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
if command == "" {
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
}
// Prepend runtime config dir env if needed
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
command = config.PrependEnv(command, map[string]string{runtimeConfig.Session.ConfigDirEnv: opts.RuntimeConfigDir})
}
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = m.tmux.KillSession(sessionID)
@@ -198,8 +204,9 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
// Accept bypass permissions warning dialog if it appears
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
// Wait for Claude to be fully ready
time.Sleep(8 * time.Second)
// Wait for runtime to be fully ready at the prompt (not just started)
runtime.SleepForReadyDelay(runtimeConfig)
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)

View File

@@ -12,13 +12,13 @@ import (
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/util"
@@ -166,11 +166,12 @@ func (m *Manager) Start(foreground bool) error {
refineryRigDir = m.workDir
}
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
// Ensure runtime settings exist in refinery/ (not refinery/rig/) so we don't
// write into the source repo. Runtime walks up the tree to find settings.
refineryParentDir := filepath.Join(m.rig.Path, "refinery")
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
if err := runtime.EnsureSettingsForRole(refineryParentDir, "refinery", runtimeConfig); err != nil {
return fmt.Errorf("ensuring runtime settings: %w", err)
}
if err := t.NewSession(sessionID, refineryRigDir); err != nil {
@@ -222,15 +223,17 @@ func (m *Manager) Start(foreground bool) error {
}
// Wait for Claude to start and show its prompt (non-fatal)
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
// WaitForRuntimeReady waits for the runtime to be ready
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
// Wait for runtime to be fully ready
runtime.SleepForReadyDelay(runtimeConfig)
_ = runtime.RunStartupFallback(t, sessionID, "refinery", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/refinery", m.rig.Name)

View File

@@ -405,6 +405,12 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
}
// Create refinery hooks for patrol triggering (at refinery/ level, not rig/)
refineryPath := filepath.Dir(refineryRigPath)
runtimeConfig := config.LoadRuntimeConfig(rigPath)
if err := m.createPatrolHooks(refineryPath, runtimeConfig); err != nil {
fmt.Printf(" Warning: Could not create refinery hooks: %v\n", err)
}
// Create empty crew directory with README (crew members added via gt crew add)
crewPath := filepath.Join(rigPath, "crew")
@@ -439,6 +445,10 @@ Use crew for your own workspace. Polecats are for batch work dispatch.
if err := os.MkdirAll(witnessPath, 0755); err != nil {
return nil, fmt.Errorf("creating witness dir: %w", err)
}
// Create witness hooks for patrol triggering
if err := m.createPatrolHooks(witnessPath, runtimeConfig); err != nil {
fmt.Printf(" Warning: Could not create witness hooks: %v\n", err)
}
// Create polecats directory (empty)
polecatsPath := filepath.Join(rigPath, "polecats")
@@ -922,6 +932,65 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName
return os.WriteFile(claudePath, []byte(content), 0644)
}
// createPatrolHooks creates .claude/settings.json with hooks for patrol roles.
// These hooks trigger gt prime on session start and inject mail, enabling
// autonomous patrol execution for Witness and Refinery roles.
func (m *Manager) createPatrolHooks(workspacePath string, runtimeConfig *config.RuntimeConfig) error {
if runtimeConfig == nil || runtimeConfig.Hooks == nil || runtimeConfig.Hooks.Provider != "claude" {
return nil
}
if runtimeConfig.Hooks.Dir == "" || runtimeConfig.Hooks.SettingsFile == "" {
return nil
}
settingsDir := filepath.Join(workspacePath, runtimeConfig.Hooks.Dir)
if err := os.MkdirAll(settingsDir, 0755); err != nil {
return fmt.Errorf("creating settings dir: %w", err)
}
// Standard patrol hooks - same as deacon
hooksJSON := `{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "gt prime && gt mail check --inject"
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "gt prime"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "gt mail check --inject"
}
]
}
]
}
}
`
settingsPath := filepath.Join(settingsDir, runtimeConfig.Hooks.SettingsFile)
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
}
// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database.
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
func (m *Manager) seedPatrolMolecules(rigPath string) error {

View File

@@ -0,0 +1,94 @@
// Package runtime provides helpers for runtime-specific integration.
package runtime
import (
"os"
"strings"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/opencode"
"github.com/steveyegge/gastown/internal/tmux"
)
// EnsureSettingsForRole installs runtime hook settings when supported.
func EnsureSettingsForRole(workDir, role string, rc *config.RuntimeConfig) error {
if rc == nil {
rc = config.DefaultRuntimeConfig()
}
if rc.Hooks == nil {
return nil
}
switch rc.Hooks.Provider {
case "claude":
return claude.EnsureSettingsForRoleAt(workDir, role, rc.Hooks.Dir, rc.Hooks.SettingsFile)
case "opencode":
return opencode.EnsurePluginAt(workDir, rc.Hooks.Dir, rc.Hooks.SettingsFile)
default:
return nil
}
}
// SessionIDFromEnv returns the runtime session ID, if present.
// It checks GT_SESSION_ID_ENV first, then falls back to CLAUDE_SESSION_ID.
func SessionIDFromEnv() string {
if envName := os.Getenv("GT_SESSION_ID_ENV"); envName != "" {
if sessionID := os.Getenv(envName); sessionID != "" {
return sessionID
}
}
return os.Getenv("CLAUDE_SESSION_ID")
}
// SleepForReadyDelay sleeps for the runtime's configured readiness delay.
func SleepForReadyDelay(rc *config.RuntimeConfig) {
if rc == nil || rc.Tmux == nil {
return
}
if rc.Tmux.ReadyDelayMs <= 0 {
return
}
time.Sleep(time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond)
}
// StartupFallbackCommands returns commands that approximate Claude hooks when hooks are unavailable.
func StartupFallbackCommands(role string, rc *config.RuntimeConfig) []string {
if rc == nil {
rc = config.DefaultRuntimeConfig()
}
if rc.Hooks != nil && rc.Hooks.Provider != "" && rc.Hooks.Provider != "none" {
return nil
}
role = strings.ToLower(role)
command := "gt prime"
if isAutonomousRole(role) {
command += " && gt mail check --inject"
}
command += " && gt nudge deacon session-started"
return []string{command}
}
// RunStartupFallback sends the startup fallback commands via tmux.
func RunStartupFallback(t *tmux.Tmux, sessionID, role string, rc *config.RuntimeConfig) error {
commands := StartupFallbackCommands(role, rc)
for _, cmd := range commands {
if err := t.NudgeSession(sessionID, cmd); err != nil {
return err
}
}
return nil
}
func isAutonomousRole(role string) bool {
switch role {
case "polecat", "witness", "refinery", "deacon":
return true
default:
return false
}
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
)
@@ -391,8 +392,9 @@ func (t *Tmux) GetPaneWorkDir(session string) (string, error) {
// FindSessionByWorkDir finds tmux sessions where the pane's current working directory
// matches or is under the target directory. Returns session names that match.
// If requireAgentRunning is true, only returns sessions that have some non-shell command running.
func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool) ([]string, error) {
// If processNames is provided, only returns sessions that match those processes.
// If processNames is nil or empty, returns all sessions matching the directory.
func (t *Tmux) FindSessionByWorkDir(targetDir string, processNames []string) ([]string, error) {
sessions, err := t.ListSessions()
if err != nil {
return nil, err
@@ -411,14 +413,13 @@ func (t *Tmux) FindSessionByWorkDir(targetDir string, requireAgentRunning bool)
// Check if workdir matches target (exact match or subdir)
if workDir == targetDir || strings.HasPrefix(workDir, targetDir+"/") {
if requireAgentRunning {
// Only include if an agent appears to be running
if t.IsAgentRunning(session) {
if len(processNames) > 0 {
if t.IsRuntimeRunning(session, processNames) {
matches = append(matches, session)
}
} else {
matches = append(matches, session)
continue
}
matches = append(matches, session)
}
}
@@ -572,6 +573,25 @@ func (t *Tmux) IsClaudeRunning(session string) bool {
return matched
}
// IsRuntimeRunning checks if a runtime appears to be running in the session.
// Only trusts the pane command - UI markers in scrollback cause false positives.
// This is the runtime-config-aware version of IsAgentRunning.
func (t *Tmux) IsRuntimeRunning(session string, processNames []string) bool {
if len(processNames) == 0 {
return false
}
cmd, err := t.GetPaneCommand(session)
if err != nil {
return false
}
for _, name := range processNames {
if cmd == name {
return true
}
}
return false
}
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
// Useful for waiting until a shell has started a new process (e.g., claude).
// Returns nil when a non-excluded command is detected, or error on timeout.
@@ -620,13 +640,12 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
return fmt.Errorf("timeout waiting for shell")
}
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
// Claude is ready when we see "> " at the start of a line (the input prompt).
// This is more reliable than just checking if node is running.
// WaitForRuntimeReady polls until the runtime's prompt indicator appears in the pane.
// Runtime is ready when we see the configured prompt prefix at the start of a line.
//
// IMPORTANT: Bootstrap vs Steady-State Observation
//
// This function uses regex to detect Claude's prompt - a ZFC violation.
// This function uses regex to detect runtime prompts - a ZFC violation.
// ZFC (Zero False Commands) principle: AI should observe AI, not regex.
//
// Bootstrap (acceptable):
@@ -643,7 +662,24 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
//
// See: gt deacon pending (ZFC-compliant AI observation)
// See: gt deacon trigger-pending (bootstrap mode, regex-based)
func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
func (t *Tmux) WaitForRuntimeReady(session string, rc *config.RuntimeConfig, timeout time.Duration) error {
if rc == nil || rc.Tmux == nil {
return nil
}
if rc.Tmux.ReadyPromptPrefix == "" {
if rc.Tmux.ReadyDelayMs <= 0 {
return nil
}
// Fallback to fixed delay when prompt detection is unavailable.
delay := time.Duration(rc.Tmux.ReadyDelayMs) * time.Millisecond
if delay > timeout {
delay = timeout
}
time.Sleep(delay)
return nil
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
// Capture last few lines of the pane
@@ -652,16 +688,17 @@ func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
time.Sleep(200 * time.Millisecond)
continue
}
// Look for Claude's prompt indicator "> " at start of line
// Look for runtime prompt indicator at start of line
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "> ") || trimmed == ">" {
prefix := strings.TrimSpace(rc.Tmux.ReadyPromptPrefix)
if strings.HasPrefix(trimmed, rc.Tmux.ReadyPromptPrefix) || (prefix != "" && trimmed == prefix) {
return nil
}
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for Claude prompt")
return fmt.Errorf("timeout waiting for runtime prompt")
}
// GetSessionInfo returns detailed information about a session.

View File

@@ -183,6 +183,7 @@ func (m *Manager) Start(foreground bool) error {
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "")
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = t.KillSession(sessionID)
@@ -193,9 +194,8 @@ func (m *Manager) Start(foreground bool) error {
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start and show its prompt (non-fatal)
// WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running
if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil {
// Wait for runtime to start and show its prompt (non-fatal)
if err := t.WaitForRuntimeReady(sessionID, runtimeConfig, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}