Extended the unified cycle system to include rig infrastructure sessions: - Witness ↔ Refinery (per rig) now cycle with C-b n/p Also moved SetCycleBindings into ConfigureGasTownSession so ALL Gas Town sessions automatically get the unified cycle bindings. Removed redundant individual calls from crew, mayor, and deacon startup code. Cycle groups are now: - Town: Mayor ↔ Deacon - Crew (per rig): All crew members in same rig - Infra (per rig): Witness ↔ Refinery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
179 lines
5.9 KiB
Go
179 lines
5.9 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/crew"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
func runCrewAt(cmd *cobra.Command, args []string) error {
|
|
var name string
|
|
|
|
// Determine crew name: from arg, or auto-detect from cwd
|
|
if len(args) > 0 {
|
|
name = args[0]
|
|
// Parse rig/name format (e.g., "beads/emma" -> rig=beads, name=emma)
|
|
if rig, crewName, ok := parseRigSlashName(name); ok {
|
|
if crewRig == "" {
|
|
crewRig = rig
|
|
}
|
|
name = crewName
|
|
}
|
|
} else {
|
|
// Try to detect from current directory
|
|
detected, err := detectCrewFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at <name>", err)
|
|
}
|
|
name = detected.crewName
|
|
if crewRig == "" {
|
|
crewRig = detected.rigName
|
|
}
|
|
fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name)
|
|
}
|
|
|
|
crewMgr, r, err := getCrewManager(crewRig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the crew worker
|
|
worker, err := crewMgr.Get(name)
|
|
if err != nil {
|
|
if err == crew.ErrCrewNotFound {
|
|
return fmt.Errorf("crew workspace '%s' not found", name)
|
|
}
|
|
return fmt.Errorf("getting crew worker: %w", err)
|
|
}
|
|
|
|
// Ensure crew workspace is on main branch (persistent roles should not use feature branches)
|
|
ensureMainBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name))
|
|
|
|
// If --no-tmux, just print the path
|
|
if crewNoTmux {
|
|
fmt.Println(worker.ClonePath)
|
|
return nil
|
|
}
|
|
|
|
// Resolve account for Claude config
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
|
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, crewAccount)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving account: %w", err)
|
|
}
|
|
if accountHandle != "" {
|
|
fmt.Printf("Using account: %s\n", accountHandle)
|
|
}
|
|
|
|
// Check if session exists
|
|
t := tmux.NewTmux()
|
|
sessionID := crewSessionName(r.Name, name)
|
|
hasSession, err := t.HasSession(sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking session: %w", err)
|
|
}
|
|
|
|
if !hasSession {
|
|
// Create new session
|
|
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
|
return fmt.Errorf("creating session: %w", err)
|
|
}
|
|
|
|
// Set environment (non-fatal: session works without these)
|
|
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
|
_ = 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)
|
|
}
|
|
|
|
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
|
// Note: ConfigureGasTownSession includes cycle bindings
|
|
theme := getThemeForRig(r.Name)
|
|
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
|
|
|
// Wait for shell to be ready after session creation
|
|
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
|
return fmt.Errorf("waiting for shell: %w", err)
|
|
}
|
|
|
|
// Get pane ID for respawn
|
|
paneID, err := t.GetPaneID(sessionID)
|
|
if err != nil {
|
|
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
|
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
|
bdActor := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
|
claudeCmd := fmt.Sprintf(`export GT_ROLE=crew GT_RIG=%s GT_CREW=%s BD_ACTOR=%s && claude --dangerously-skip-permissions "gt prime"`, r.Name, name, bdActor)
|
|
if err := t.RespawnPane(paneID, claudeCmd); err != nil {
|
|
return fmt.Errorf("starting claude: %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
|
|
// Uses both pane command check and UI marker detection to avoid
|
|
// restarting when user is in a subshell spawned from Claude
|
|
if !t.IsClaudeRunning(sessionID) {
|
|
// Claude has exited, restart it using respawn-pane
|
|
fmt.Printf("Claude exited, restarting...\n")
|
|
|
|
// Get pane ID for respawn
|
|
paneID, err := t.GetPaneID(sessionID)
|
|
if err != nil {
|
|
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
|
|
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
|
|
bdActor := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
|
claudeCmd := fmt.Sprintf(`export GT_ROLE=crew GT_RIG=%s GT_CREW=%s BD_ACTOR=%s && claude --dangerously-skip-permissions "gt prime"`, r.Name, name, bdActor)
|
|
if err := t.RespawnPane(paneID, claudeCmd); err != nil {
|
|
return fmt.Errorf("restarting claude: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we're already in the target session
|
|
if isInTmuxSession(sessionID) {
|
|
// We're in the session at a shell prompt - just start Claude directly
|
|
// Pass "gt prime" as initial prompt so Claude loads context immediately
|
|
fmt.Printf("Starting Claude in current session...\n")
|
|
return execClaude("gt prime")
|
|
}
|
|
|
|
// If inside tmux (but different session), don't switch - just inform user
|
|
if tmux.IsInsideTmux() {
|
|
fmt.Printf("Started %s/%s. Use C-b s to switch.\n", r.Name, name)
|
|
return nil
|
|
}
|
|
|
|
// Outside tmux: attach unless --detached flag is set
|
|
if crewDetached {
|
|
fmt.Printf("Started %s/%s. Run 'gt crew at %s' to attach.\n", r.Name, name, name)
|
|
return nil
|
|
}
|
|
|
|
// Attach to session
|
|
return attachToTmuxSession(sessionID)
|
|
}
|