Files
gastown/internal/cmd/crew_at.go
julianknutsen e9a013c0d2 fix: add BEADS_NO_DAEMON to crew for isolated clone context
Crew workspaces use clones with redirected beads directories, like
polecat and refinery. They should bypass the bd daemon for fresh
data and isolation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:52:30 -08:00

275 lines
9.5 KiB
Go

package cmd
import (
"fmt"
"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/runtime"
"github.com/steveyegge/gastown/internal/session"
"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 {
// Try to show available crew members if we can detect the rig
hint := "\n\nUsage: gt crew at <name>"
if crewRig != "" {
if mgr, _, mgrErr := getCrewManager(crewRig); mgrErr == nil {
if members, listErr := mgr.List(); listErr == nil && len(members) > 0 {
hint = fmt.Sprintf("\n\nAvailable crew in %s:", crewRig)
for _, m := range members {
hint += fmt.Sprintf("\n %s", m.Name)
}
}
}
}
return fmt.Errorf("could not detect crew workspace from current directory: %w%s", err, hint)
}
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 default branch (persistent roles should not use feature branches)
ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", r.Name, name), r.Path)
// If --no-tmux, just print the path
if crewNoTmux {
fmt.Println(worker.ClonePath)
return nil
}
// Resolve account for runtime 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)
}
runtimeConfig := config.LoadRuntimeConfig(r.Path)
_ = runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig)
// 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)
}
// 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, runtimeConfig.Tmux.ProcessNames)
if err == nil && len(existingSessions) > 0 {
// Found an existing session with runtime running in this directory
existingSession := existingSessions[0]
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")
// If inside tmux (but different session), inform user
if tmux.IsInsideTmux() {
fmt.Printf("Use C-b s to switch to '%s'\n", existingSession)
return nil
}
// Outside tmux: attach unless --detached flag is set
if crewDetached {
fmt.Printf("Existing session: '%s'. Run 'tmux attach -t %s' to attach.\n",
existingSession, existingSession)
return nil
}
// Attach to existing session
return attachToTmuxSession(existingSession)
}
}
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)
// Use centralized AgentEnv for consistency across all role startup paths
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "crew",
Rig: r.Name,
AgentName: name,
TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(r.Path),
RuntimeConfigDir: claudeConfigDir,
BeadsNoDaemon: true,
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// 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, constants.ShellReadyTimeout); 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)
}
// Build startup beacon for predecessor discovery via /resume
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
// The SessionStart hook handles context injection (gt prime --hook)
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "start",
})
// Use respawn-pane to replace shell with runtime directly
// This gives cleaner lifecycle: runtime exits → session ends (no intermediate shell)
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, 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 runtime: %w", err)
}
fmt.Printf("%s Created session for %s/%s\n",
style.Bold.Render("✓"), r.Name, name)
} else {
// 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 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)...) {
// Runtime has exited, restart it using respawn-pane
fmt.Printf("Runtime exited, restarting...\n")
// Get pane ID for respawn
paneID, err := t.GetPaneID(sessionID)
if err != nil {
return fmt.Errorf("getting pane ID: %w", err)
}
// Build startup beacon for predecessor discovery via /resume
// Use FormatStartupNudge instead of bare "gt prime" which confuses agents
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "restart",
})
// Use respawn-pane to replace shell with runtime directly
// Export GT_ROLE and BD_ACTOR since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(r.Name, name, r.Path, beacon, 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 runtime: %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 the agent directly
// Build startup beacon for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "start",
})
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, crewAgentOverride)
if err != nil {
return fmt.Errorf("resolving agent: %w", err)
}
fmt.Printf("Starting %s in current session...\n", agentCfg.Command)
return execAgent(agentCfg, beacon)
}
// 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)
}