* fix(sling_test): update test for cook dir change
The cook command no longer needs database context and runs from cwd,
not the target rig directory. Update test to match this behavior
change from bd2a5ab5.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(tests): skip tests requiring missing binaries, handle --allow-stale
- Add skipIfAgentBinaryMissing helper to skip tests when codex/gemini
binaries aren't available in the test environment
- Update rig manager test stub to handle --allow-stale flag
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(config): remove BEADS_DIR from agent environment
Stop exporting BEADS_DIR in AgentEnv - agents should use beads redirect
mechanism instead of relying on environment variable. This prevents
prefix mismatches when agents operate across different beads databases.
Changes:
- Remove BeadsDir field from AgentEnvConfig
- Remove BEADS_DIR from env vars set on agent sessions
- Update doctor env_check to not expect BEADS_DIR
- Update all manager Start() calls to not pass BeadsDir
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix(doctor): detect BEADS_DIR in tmux session environment
Add a doctor check that warns when BEADS_DIR is set in any Gas Town
tmux session. BEADS_DIR in the environment overrides prefix-based
routing and breaks multi-rig lookups - agents should use the beads
redirect mechanism instead.
The check:
- Iterates over all Gas Town tmux sessions (gt-* and hq-*)
- Checks if BEADS_DIR is set in the session environment
- Returns a warning with fix hint to restart sessions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"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"
|
|
)
|
|
|
|
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})
|
|
}
|
|
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) {
|
|
// 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)
|
|
}
|