This commit is contained in:
Ben Kraus
2026-01-02 09:21:49 -07:00
committed by Cameron Palmer
parent f4cbcb4ce9
commit 38adfa4d8b
33 changed files with 1044 additions and 172 deletions

View File

@@ -73,7 +73,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 +87,8 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
fmt.Printf("Using account: %s\n", accountHandle)
}
runtimeConfig := config.LoadRuntimeConfig(r.Path)
// Check if session exists
t := tmux.NewTmux()
sessionID := crewSessionName(r.Name, name)
@@ -95,15 +97,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 +139,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 +160,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 +196,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...)
@@ -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 {
@@ -347,8 +348,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 {
@@ -427,8 +428,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 {

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

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/style"
"github.com/steveyegge/gastown/internal/templates"
@@ -1499,22 +1500,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

@@ -19,6 +19,8 @@ import (
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
@@ -317,6 +319,86 @@ func discoverAllRigs(townRoot string) ([]*rig.Rig, error) {
return rigMgr.DiscoverRigs()
}
// ensureRefinerySession creates a refinery tmux session if it doesn't exist.
// Returns true if a new session was created, false if it already existed.
func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
t := tmux.NewTmux()
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
// Check if session already exists
running, err := t.HasSession(sessionName)
if err != nil {
return false, fmt.Errorf("checking session: %w", err)
}
if running {
return false, nil
}
// Working directory is the refinery's rig clone
refineryRigDir := filepath.Join(r.Path, "refinery", "rig")
if _, err := os.Stat(refineryRigDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist
refineryRigDir = r.Path
}
// Ensure runtime settings exist (autonomous role needs mail in SessionStart)
runtimeConfig := config.LoadRuntimeConfig(r.Path)
if err := runtime.EnsureSettingsForRole(refineryRigDir, "refinery", runtimeConfig); err != nil {
return false, fmt.Errorf("ensuring runtime settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionName, refineryRigDir); err != nil {
return false, fmt.Errorf("creating session: %w", err)
}
// Set environment
bdActor := fmt.Sprintf("%s/refinery", rigName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "refinery")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Set beads environment
beadsDir := filepath.Join(r.Path, "mayor", "rig", ".beads")
_ = t.SetEnvironment(sessionName, "BEADS_DIR", beadsDir)
_ = t.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1")
_ = t.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", rigName))
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery")
// Launch Claude directly (no respawn loop - daemon handles restart)
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("refinery", bdActor, "", "")); err != nil {
return false, fmt.Errorf("sending command: %w", err)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
_ = runtime.RunStartupFallback(t, sessionName, "refinery", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/refinery", rigName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "deacon",
Topic: "patrol",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous patrol execution.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal
return true, nil
}
func runShutdown(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()

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

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/deacon"
@@ -18,6 +19,8 @@ import (
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
@@ -249,6 +252,127 @@ func ensureDaemon(townRoot string) error {
return nil
}
// ensureSession starts a Claude session if not running.
func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
running, err := t.HasSession(sessionName)
if err != nil {
return err
}
if running {
return nil
}
// Create session
if err := t.NewSession(sessionName, workDir); err != nil {
return err
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", role)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", role)
// Apply theme based on role (non-fatal: theming failure doesn't affect operation)
switch role {
case "mayor":
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
case "deacon":
theme := tmux.DeaconTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
}
// Launch runtime
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
var claudeCmd string
runtimeCmd := config.GetRuntimeCommand("")
runtimeConfig := config.LoadRuntimeConfig("")
if role == "deacon" {
// Deacon uses respawn loop
prefix := "GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon"
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
prefix = prefix + " GT_SESSION_ID_ENV=" + runtimeConfig.Session.SessionIDEnv
}
claudeCmd = `export ` + prefix + ` && while true; do echo "⛪ Starting Deacon session..."; ` + runtimeCmd + `; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
} else {
claudeCmd = config.BuildAgentStartupCommand(role, role, "", "")
}
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
// Note: Deacon respawn loop makes beacon tricky - Claude restarts multiple times
// For non-respawn (mayor), inject beacon
if role != "deacon" {
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: role,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
_ = runtime.RunStartupFallback(t, sessionName, role, runtimeConfig)
}
return nil
}
// ensureWitness starts a witness session for a rig.
func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error {
running, err := t.HasSession(sessionName)
if err != nil {
return err
}
if running {
return nil
}
// Create session in rig directory
if err := t.NewSession(sessionName, rigPath); err != nil {
return err
}
// Set environment (non-fatal: session works without these)
bdActor := fmt.Sprintf("%s/witness", rigName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "witness")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName)
// Launch runtime using runtime config
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
runtimeConfig := config.LoadRuntimeConfig(rigPath)
claudeCmd := config.BuildAgentStartupCommand("witness", bdActor, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
_ = runtime.RunStartupFallback(t, sessionName, "witness", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/witness", rigName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "deacon",
Topic: "patrol",
}) // Non-fatal
return nil
}
// discoverRigs finds all rigs in the town.
func discoverRigs(townRoot string) []string {
var rigs []string

View File

@@ -5,8 +5,15 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra"
"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/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
@@ -262,6 +269,87 @@ func witnessSessionName(rigName string) string {
return fmt.Sprintf("gt-%s-witness", rigName)
}
// ensureWitnessSession creates a witness tmux session if it doesn't exist.
// Returns true if a new session was created, false if it already existed.
func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) {
t := tmux.NewTmux()
sessionName := witnessSessionName(rigName)
// Check if session already exists
running, err := t.HasSession(sessionName)
if err != nil {
return false, fmt.Errorf("checking session: %w", err)
}
if running {
return false, nil
}
// Working directory is the witness's rig clone (if it exists) or witness dir
// This ensures gt prime detects the Witness role correctly
witnessDir := filepath.Join(r.Path, "witness", "rig")
if _, err := os.Stat(witnessDir); os.IsNotExist(err) {
// Try witness/ without rig subdirectory
witnessDir = filepath.Join(r.Path, "witness")
if _, err := os.Stat(witnessDir); os.IsNotExist(err) {
// Fall back to rig path (shouldn't happen in normal setup)
witnessDir = r.Path
}
}
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
runtimeConfig := config.LoadRuntimeConfig(r.Path)
if err := runtime.EnsureSettingsForRole(witnessDir, "witness", runtimeConfig); err != nil {
return false, fmt.Errorf("ensuring runtime settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionName, witnessDir); err != nil {
return false, fmt.Errorf("creating session: %w", err)
}
// Set environment
bdActor := fmt.Sprintf("%s/witness", rigName)
t.SetEnvironment(sessionName, "GT_ROLE", "witness")
t.SetEnvironment(sessionName, "GT_RIG", rigName)
t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
// Launch Claude directly (no shell respawn loop)
// Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("witness", bdActor, "", "")); err != nil {
return false, fmt.Errorf("sending command: %w", err)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
_ = runtime.RunStartupFallback(t, sessionName, "witness", runtimeConfig)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/witness", rigName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "deacon",
Topic: "patrol",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous patrol execution.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal
return true, nil
}
func runWitnessAttach(cmd *cobra.Command, args []string) error {
rigName := ""
if len(args) > 0 {