Files
gastown/internal/cmd/crew_at.go
propane a4e8700173 fix(statusline): ensure crew sessions have correct hook display
Root cause: tmux statusline showed wrong hook for all java crewmembers
because GT_CREW env var wasn't set in tmux session environment.

Changes:
- statusline.go: Add early return in getHookedWork() when identity is empty
  to prevent returning ALL hooked beads regardless of assignee
- crew_at.go: Call SetEnvironment in the restart path so sessions created
  before GT_CREW was being set get it on restart

Fixes gt-zxnr.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:15:29 -08:00

350 lines
12 KiB
Go

package cmd
import (
"fmt"
"os"
"strings"
"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/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"
)
// crewAtRetried tracks if we've already retried after stale session cleanup
var crewAtRetried bool
func runCrewAt(cmd *cobra.Command, args []string) error {
var name string
// Debug mode: --debug flag or GT_DEBUG env var
debug := crewDebug || os.Getenv("GT_DEBUG") != ""
if debug {
cwd, _ := os.Getwd()
fmt.Printf("[DEBUG] runCrewAt: args=%v, crewRig=%q, cwd=%q\n", args, crewRig, cwd)
}
// 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)
}
if debug {
fmt.Printf("[DEBUG] after detection: name=%q, crewRig=%q\n", name, crewRig)
}
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)
if err := runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig); err != nil {
// Non-fatal but log warning - missing settings can cause agents to start without hooks
style.PrintWarning("could not ensure settings for %s: %v", name, err)
}
// Check if session exists
t := tmux.NewTmux()
sessionID := crewSessionName(r.Name, name)
if debug {
fmt.Printf("[DEBUG] sessionID=%q (r.Name=%q, name=%q)\n", sessionID, r.Name, name)
}
hasSession, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if debug {
fmt.Printf("[DEBUG] hasSession=%v\n", hasSession)
}
// 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,
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})
}
// Note: Don't call KillPaneProcesses here - this is a NEW session with just
// a fresh shell. Killing it would destroy the pane before we can respawn.
// KillPaneProcesses is only needed when restarting in an EXISTING session
// where Claude/Node processes might be running and ignoring SIGHUP.
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",
})
// Ensure tmux session environment is set (for gt status-line to read).
// Sessions created before this was added may be missing GT_CREW, etc.
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "crew",
Rig: r.Name,
AgentName: name,
TownRoot: townRoot,
RuntimeConfigDir: claudeConfigDir,
BeadsNoDaemon: true,
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Use respawn-pane to replace shell with runtime directly
// Export GT_ROLE and BD_ACTOR in the command since pane inherits from shell, not session env
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})
}
// Kill all processes in the pane before respawning to prevent orphan leaks
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore
if err := t.KillPaneProcesses(paneID); err != nil {
// Non-fatal but log the warning
style.PrintWarning("could not kill pane processes: %v", err)
}
if err := t.RespawnPane(paneID, startupCmd); err != nil {
// If pane is stale (session exists but pane doesn't), recreate the session
if strings.Contains(err.Error(), "can't find pane") {
if crewAtRetried {
return fmt.Errorf("stale session persists after cleanup: %w", err)
}
fmt.Printf("Stale session detected, recreating...\n")
if killErr := t.KillSession(sessionID); killErr != nil {
return fmt.Errorf("failed to kill stale session: %w", killErr)
}
crewAtRetried = true
defer func() { crewAtRetried = false }()
return runCrewAt(cmd, args) // Retry with fresh session
}
return fmt.Errorf("restarting runtime: %w", err)
}
}
}
// Check if we're already in the target session
if isInTmuxSession(sessionID) {
// Check if agent is already running - don't restart if so
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)...) {
// Agent is already running, nothing to do
fmt.Printf("Already in %s session with %s running.\n", name, agentCfg.Command)
return nil
}
// We're in the session at a shell prompt - start the agent
// 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",
})
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
insideTmux := tmux.IsInsideTmux()
if debug {
fmt.Printf("[DEBUG] tmux.IsInsideTmux()=%v\n", insideTmux)
}
if insideTmux {
fmt.Printf("Session %s ready. Use C-b s to switch.\n", sessionID)
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 - show which session we're attaching to
fmt.Printf("Attaching to %s...\n", sessionID)
if debug {
fmt.Printf("[DEBUG] calling attachToTmuxSession(%q)\n", sessionID)
}
return attachToTmuxSession(sessionID)
}