From f284941a5b87f2c3af8540a46f7118e1f1c44179 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 23:33:14 -0800 Subject: [PATCH] Rename gt recycle to gt handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent naming throughout: - internal/cmd/recycle.go → handoff.go - All variable/function names updated - /handoff Claude Code command updated - polecat.md prompt updated Also includes session.go doc improvements (nudge preference). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/handoff.md | 6 +- internal/cmd/handoff.go | 623 ++++++++++-------------------------- internal/cmd/recycle.go | 241 -------------- internal/cmd/session.go | 18 +- prompts/roles/polecat.md | 2 +- 5 files changed, 182 insertions(+), 708 deletions(-) delete mode 100644 internal/cmd/recycle.go diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md index 900313c7..5cd4af04 100644 --- a/.claude/commands/handoff.md +++ b/.claude/commands/handoff.md @@ -1,12 +1,12 @@ --- description: Hand off to fresh session, work continues from hook -allowed-tools: Bash(gt recycle) +allowed-tools: Bash(gt handoff) --- -Execute `gt recycle` to hand off to a fresh session: +Execute `gt handoff` to hand off to a fresh session: ```bash -gt recycle +gt handoff ``` End watch. A new session takes over, picking up any molecule on the hook. diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index dfb9f663..0a101a9f 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -1,533 +1,240 @@ package cmd import ( - "encoding/json" "fmt" "os" "os/exec" - "path/filepath" - "strconv" "strings" - "syscall" - "time" "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" - "github.com/steveyegge/gastown/internal/workspace" -) - -// HandoffAction for handoff command. -type HandoffAction string - -const ( - HandoffCycle HandoffAction = "cycle" // Restart with handoff mail - HandoffRestart HandoffAction = "restart" // Fresh restart, no handoff - HandoffShutdown HandoffAction = "shutdown" // Terminate, no restart + "github.com/steveyegge/gastown/internal/tmux" ) var handoffCmd = &cobra.Command{ - Use: "handoff", - Short: "Request lifecycle action (retirement/restart)", - Long: `Request a lifecycle action from your manager. + Use: "handoff [role]", + Short: "Hand off to a fresh session, work continues from hook", + Long: `End watch. Hand off to a fresh agent session. -This command initiates graceful retirement: -1. Verifies git state is clean -2. For polecats (shutdown): auto-submits MR to merge queue -3. Sends handoff mail to yourself (for cycle) -4. Sends lifecycle request to your manager -5. Sets requesting state and waits for retirement +This command uses tmux respawn-pane to end the current session and restart it +with a fresh Claude instance, running the full startup/priming sequence. -Your manager (daemon for Mayor/Witness, witness for polecats) will -verify the request and terminate your session. For cycle/restart, -a new session starts and reads your handoff mail to continue work. - -Polecat auto-MR: -When a polecat runs 'gt handoff' (default: shutdown), the current branch -is automatically submitted to the merge queue if it follows the -polecat// naming convention. The Refinery will process -the merge request. - -Flags: - --cycle Restart with handoff mail (default for Mayor/Witness) - --restart Fresh restart, no handoff context - --shutdown Terminate without restart (default for polecats) +When run without arguments, hands off the current session. +When given a role name, hands off that role's session (and switches to it). Examples: - gt handoff # Use role-appropriate default - gt handoff --cycle # Restart with context handoff - gt handoff --restart # Fresh restart -`, + gt handoff # Hand off current session + gt handoff crew # Hand off crew session (auto-detect name) + gt handoff mayor # Hand off mayor session + gt handoff witness # Hand off witness session for current rig + +Any molecule on the hook will be auto-continued by the new session.`, RunE: runHandoff, } var ( - handoffCycle bool - handoffRestart bool - handoffShutdown bool - handoffForce bool - handoffMessage string + handoffWatch bool + handoffDryRun bool ) func init() { - handoffCmd.Flags().BoolVar(&handoffCycle, "cycle", false, "Restart with handoff mail") - handoffCmd.Flags().BoolVar(&handoffRestart, "restart", false, "Fresh restart, no handoff") - handoffCmd.Flags().BoolVar(&handoffShutdown, "shutdown", false, "Terminate without restart") - handoffCmd.Flags().BoolVarP(&handoffForce, "force", "f", false, "Skip pre-flight checks") - handoffCmd.Flags().StringVarP(&handoffMessage, "message", "m", "", "Handoff message for successor") - + handoffCmd.Flags().BoolVarP(&handoffWatch, "watch", "w", true, "Switch to new session (for remote handoff)") + handoffCmd.Flags().BoolVarP(&handoffDryRun, "dry-run", "n", false, "Show what would be done without executing") rootCmd.AddCommand(handoffCmd) } func runHandoff(cmd *cobra.Command, args []string) error { - // Detect our role - role := detectHandoffRole() - if role == RoleUnknown { - return fmt.Errorf("cannot detect agent role (set GT_ROLE or run from known context)") + t := tmux.NewTmux() + + // Verify we're in tmux + if !tmux.IsInsideTmux() { + return fmt.Errorf("not running in tmux - cannot hand off") } - // Determine action - action := determineAction(role) + pane := os.Getenv("TMUX_PANE") + if pane == "" { + return fmt.Errorf("TMUX_PANE not set - cannot hand off") + } - fmt.Printf("Agent role: %s\n", style.Bold.Render(string(role))) - fmt.Printf("Action: %s\n", style.Bold.Render(string(action))) - - // Find workspace - townRoot, err := workspace.FindFromCwdOrError() + // Get current session name + currentSession, err := getCurrentTmuxSession() if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) + return fmt.Errorf("getting session name: %w", err) } - // Pre-flight checks (unless forced) - if !handoffForce { - if err := preFlightChecks(); err != nil { - return fmt.Errorf("pre-flight check failed: %w\n\nUse --force to skip checks", err) + // Determine target session + targetSession := currentSession + if len(args) > 0 { + // User specified a role to hand off + targetSession, err = resolveRoleToSession(args[0]) + if err != nil { + return fmt.Errorf("resolving role: %w", err) } } - // For polecats shutting down with work complete, auto-submit MR to merge queue - if role == RolePolecat && action == HandoffShutdown { - if err := submitMRForPolecat(); err != nil { - // Non-fatal: warn but continue with handoff - fmt.Printf("%s Could not auto-submit MR: %v\n", style.Warning.Render("Warning:"), err) - fmt.Println(style.Dim.Render(" You may need to run 'gt mq submit' manually")) - } else { - fmt.Printf("%s Auto-submitted work to merge queue\n", style.Bold.Render("✓")) - } - } - - // For cycle, update handoff bead for successor - if action == HandoffCycle { - if err := sendHandoffMail(role, townRoot); err != nil { - return fmt.Errorf("updating handoff bead: %w", err) - } - fmt.Printf("%s Updated handoff bead for successor\n", style.Bold.Render("✓")) - } - - // Send lifecycle request to manager - manager := getManager(role) - - if err := sendLifecycleRequest(manager, role, action, townRoot); err != nil { - return fmt.Errorf("sending lifecycle request: %w", err) - } - fmt.Printf("%s Sent %s request to %s\n", style.Bold.Render("✓"), action, manager) - - // Signal daemon for immediate processing (if manager is deacon) - if manager == "deacon/" { - if err := signalDaemon(townRoot); err != nil { - // Non-fatal: daemon will eventually poll - fmt.Printf("%s Could not signal daemon (will poll): %v\n", style.Dim.Render("○"), err) - } else { - fmt.Printf("%s Signaled daemon for immediate processing\n", style.Bold.Render("✓")) - } - } - - // Set requesting state - if err := setRequestingState(role, action, townRoot); err != nil { - fmt.Printf("Warning: failed to set state: %v\n", err) - } - - // Wait for retirement with timeout warning - fmt.Println() - fmt.Printf("%s Waiting for retirement...\n", style.Dim.Render("◌")) - fmt.Println(style.Dim.Render("(Manager will terminate this session)")) - - // Wait with periodic warnings - manager should kill us - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - waitStart := time.Now() - for { - select { - case <-ticker.C: - elapsed := time.Since(waitStart).Round(time.Second) - fmt.Printf("%s Still waiting (%v elapsed)...\n", style.Dim.Render("◌"), elapsed) - if elapsed >= 2*time.Minute { - fmt.Println(style.Dim.Render(" Hint: If manager isn't responding, you may need to:")) - fmt.Println(style.Dim.Render(" - Check if daemon/witness is running")) - fmt.Println(style.Dim.Render(" - Use Ctrl+C to abort and manually exit")) - } - } - } -} - -// detectHandoffRole figures out what kind of agent we are. -// Uses GT_ROLE env var, tmux session name, or directory context. -func detectHandoffRole() Role { - // Check GT_ROLE environment variable first - if role := os.Getenv("GT_ROLE"); role != "" { - switch strings.ToLower(role) { - case "mayor": - return RoleMayor - case "witness": - return RoleWitness - case "refinery": - return RoleRefinery - case "polecat": - return RolePolecat - case "crew": - return RoleCrew - } - } - - // Check tmux session name - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() - if err == nil { - sessionName := strings.TrimSpace(string(out)) - if sessionName == "gt-mayor" { - return RoleMayor - } - if strings.HasSuffix(sessionName, "-witness") { - return RoleWitness - } - if strings.HasSuffix(sessionName, "-refinery") { - return RoleRefinery - } - // Crew sessions: gt--crew- - if strings.Contains(sessionName, "-crew-") { - return RoleCrew - } - // Polecat sessions: gt-- - if strings.HasPrefix(sessionName, "gt-") && strings.Count(sessionName, "-") >= 2 { - return RolePolecat - } - } - - // Fall back to directory-based detection - cwd, err := os.Getwd() + // Build the restart command + restartCmd, err := buildRestartCommand(targetSession) if err != nil { - return RoleUnknown + return err } - townRoot, err := workspace.FindFromCwd() - if err != nil || townRoot == "" { - return RoleUnknown + // If handing off a different session, we need to find its pane and respawn there + if targetSession != currentSession { + return handoffRemoteSession(t, targetSession, restartCmd) } - ctx := detectRole(cwd, townRoot) - return ctx.Role -} + // Handing off ourselves - print feedback then respawn + fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), currentSession) -// determineAction picks the action based on flags or role default. -func determineAction(role Role) HandoffAction { - // Explicit flags take precedence - if handoffCycle { - return HandoffCycle - } - if handoffRestart { - return HandoffRestart - } - if handoffShutdown { - return HandoffShutdown - } - - // Role-based defaults - switch role { - case RolePolecat: - return HandoffShutdown // Ephemeral, work is done - case RoleMayor, RoleWitness, RoleRefinery: - return HandoffCycle // Long-running, preserve context - case RoleCrew: - return HandoffCycle // Persistent workspace, preserve context - default: - return HandoffCycle - } -} - -// preFlightChecks verifies it's safe to retire. -func preFlightChecks() error { - // Check git status - cmd := exec.Command("git", "status", "--porcelain") - out, err := cmd.Output() - if err != nil { - // Not a git repo, that's fine + // Dry run mode - show what would happen + if handoffDryRun { + fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd) return nil } - if len(strings.TrimSpace(string(out))) > 0 { - return fmt.Errorf("uncommitted changes in git working tree") - } - - return nil + // Use exec to respawn the pane - this kills us and restarts + return t.RespawnPane(pane, restartCmd) } -// getManager returns the address of our lifecycle manager. -// For polecats and refineries, it detects the rig from context. -func getManager(role Role) string { - switch role { - case RoleMayor, RoleWitness: - return "deacon/" - case RolePolecat, RoleRefinery: - // Detect rig from current directory context - rig := detectRigFromContext() +// getCurrentTmuxSession returns the current tmux session name. +func getCurrentTmuxSession() (string, error) { + out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// resolveRoleToSession converts a role name to a tmux session name. +// For roles that need context (crew, witness, refinery), it auto-detects from environment. +func resolveRoleToSession(role string) (string, error) { + switch strings.ToLower(role) { + case "mayor", "may": + return "gt-mayor", nil + + case "deacon", "dea": + return "gt-deacon", nil + + case "crew": + // Try to get rig and crew name from environment or cwd + rig := os.Getenv("GT_RIG") + crewName := os.Getenv("GT_CREW") + if rig == "" || crewName == "" { + // Try to detect from cwd + detected, err := detectCrewFromCwd() + if err == nil { + rig = detected.rigName + crewName = detected.crewName + } + } + if rig == "" || crewName == "" { + return "", fmt.Errorf("cannot determine crew identity - run from crew directory or specify GT_RIG/GT_CREW") + } + return fmt.Sprintf("gt-%s-crew-%s", rig, crewName), nil + + case "witness", "wit": + rig := os.Getenv("GT_RIG") if rig == "" { - // Fallback if rig detection fails - this shouldn't happen - // in normal operation but is better than a literal placeholder - return "deacon/" + return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") } - return rig + "/witness" - case RoleCrew: - return "deacon/" // Crew lifecycle managed by deacon + return fmt.Sprintf("gt-%s-witness", rig), nil + + case "refinery", "ref": + rig := os.Getenv("GT_RIG") + if rig == "" { + return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") + } + return fmt.Sprintf("gt-%s-refinery", rig), nil + default: - return "deacon/" + // Assume it's a direct session name + return role, nil } } -// detectRigFromContext determines the rig name from the current directory. -func detectRigFromContext() string { - cwd, err := os.Getwd() +// buildRestartCommand creates the gt command to restart a session. +func buildRestartCommand(sessionName string) (string, error) { + switch { + case sessionName == "gt-mayor": + return "gt may at", nil + + case sessionName == "gt-deacon": + return "gt dea at", nil + + case strings.Contains(sessionName, "-crew-"): + // gt--crew- + // The attach command can auto-detect from cwd, so just use `gt crew at` + return "gt crew at", nil + + case strings.HasSuffix(sessionName, "-witness"): + // gt--witness + return "gt wit at", nil + + case strings.HasSuffix(sessionName, "-refinery"): + // gt--refinery + return "gt ref at", nil + + default: + return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName) + } +} + +// handoffRemoteSession respawns a different session and optionally switches to it. +func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error { + // Check if target session exists + exists, err := t.HasSession(targetSession) if err != nil { - return "" + return fmt.Errorf("checking session: %w", err) + } + if !exists { + return fmt.Errorf("session '%s' not found - is the agent running?", targetSession) } - townRoot, err := workspace.FindFromCwd() - if err != nil || townRoot == "" { - return "" - } - - ctx := detectRole(cwd, townRoot) - return ctx.Rig -} - -// sendHandoffMail updates the pinned handoff bead for the successor to read. -func sendHandoffMail(role Role, townRoot string) error { - // Build handoff content - content := handoffMessage - if content == "" { - content = fmt.Sprintf(`🤝 HANDOFF: Session cycling - -Time: %s -Role: %s -Action: cycle - -Check bd ready for pending work. -Check gt mail inbox for messages received during transition. -`, time.Now().Format(time.RFC3339), role) - } - - // Determine the handoff role key - // For role-specific handoffs, use the role name - roleKey := string(role) - - // Update the pinned handoff bead - bd := beads.New(townRoot) - if err := bd.UpdateHandoffContent(roleKey, content); err != nil { - return fmt.Errorf("updating handoff bead: %w", err) - } - - return nil -} - -// getPolecatName extracts the polecat name from the tmux session. -// Returns empty string if not a polecat session. -func getPolecatName() string { - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() + // Get the pane ID for the target session + targetPane, err := getSessionPane(targetSession) if err != nil { - return "" + return fmt.Errorf("getting target pane: %w", err) } - sessionName := strings.TrimSpace(string(out)) - // Polecat sessions: gt-- - if strings.HasPrefix(sessionName, "gt-") { - parts := strings.SplitN(sessionName, "-", 3) - if len(parts) >= 3 { - return parts[2] // The polecat name + fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), targetSession) + + // Dry run mode + if handoffDryRun { + fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", targetPane, restartCmd) + if handoffWatch { + fmt.Printf("Would execute: tmux switch-client -t %s\n", targetSession) } - } - return "" -} - -// getCrewIdentity extracts the crew identity from the tmux session. -// Returns format: -crew- (e.g., gastown-crew-max) -func getCrewIdentity() string { - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() - if err != nil { - return "" - } - sessionName := strings.TrimSpace(string(out)) - - // Crew sessions: gt--crew- - if strings.HasPrefix(sessionName, "gt-") && strings.Contains(sessionName, "-crew-") { - // Remove "gt-" prefix to get -crew- - return strings.TrimPrefix(sessionName, "gt-") - } - return "" -} - -// sendLifecycleRequest sends the lifecycle request to our manager. -func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error { - // Build identity for the LIFECYCLE message - // The daemon parses identity from "LIFECYCLE: requesting " - identity := string(role) - - switch role { - case RoleCrew: - // Crew identity: -crew- (e.g., gastown-crew-max) - if crewID := getCrewIdentity(); crewID != "" { - identity = crewID - } - case RolePolecat: - // Polecat identity would need similar handling if routed to deacon - } - - subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", identity, action) - body := fmt.Sprintf(`Lifecycle request from %s. - -Action: %s -Time: %s - -Please verify state and execute lifecycle action. -`, identity, action, time.Now().Format(time.RFC3339)) - - // Send via gt mail (syntax: gt mail send -s -m ) - cmd := exec.Command("gt", "mail", "send", manager, - "-s", subject, - "-m", body, - ) - cmd.Dir = townRoot - - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, string(out)) - } - - return nil -} - -// submitMRForPolecat submits the current branch to the merge queue. -// This is called automatically when a polecat shuts down with completed work. -func submitMRForPolecat() error { - // Check if we're on a polecat branch with work to submit - cmd := exec.Command("git", "branch", "--show-current") - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("getting current branch: %w", err) - } - branch := strings.TrimSpace(string(out)) - - // Skip if on main/master (no work to submit) - if branch == "main" || branch == "master" || branch == "" { - return nil // Nothing to submit, that's OK - } - - // Check if branch follows polecat// pattern - parts := strings.Split(branch, "/") - if len(parts) < 3 || parts[0] != "polecat" { - // Not a polecat work branch, skip return nil } - // Run gt mq submit --no-cleanup (handoff manages lifecycle itself) - submitCmd := exec.Command("gt", "mq", "submit", "--no-cleanup") - submitOutput, err := submitCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s", strings.TrimSpace(string(submitOutput))) + // Respawn the remote session's pane + if err := t.RespawnPane(targetPane, restartCmd); err != nil { + return fmt.Errorf("respawning pane: %w", err) } - // Print the submit output (trimmed) - output := strings.TrimSpace(string(submitOutput)) - if output != "" { - for _, line := range strings.Split(output, "\n") { - fmt.Printf(" %s\n", line) + // If --watch, switch to that session + if handoffWatch { + fmt.Printf("Switching to %s...\n", targetSession) + // Use tmux switch-client to move our view to the target session + if err := exec.Command("tmux", "switch-client", "-t", targetSession).Run(); err != nil { + // Non-fatal - they can manually switch + fmt.Printf("Note: Could not auto-switch (use: tmux switch-client -t %s)\n", targetSession) } } return nil } -// setRequestingState updates state.json to indicate we're requesting lifecycle action. -func setRequestingState(role Role, action HandoffAction, townRoot string) error { - // Determine state file location based on role - var stateFile string - switch role { - case RoleMayor: - stateFile = filepath.Join(townRoot, "mayor", "state.json") - case RoleWitness: - // Would need rig context - stateFile = filepath.Join(townRoot, "witness", "state.json") - case RoleCrew: - // Crew state: //crew//state.json - if crewID := getCrewIdentity(); crewID != "" { - // crewID format: -crew- - parts := strings.SplitN(crewID, "-crew-", 2) - if len(parts) == 2 { - stateFile = filepath.Join(townRoot, parts[0], "crew", parts[1], "state.json") - } - } - if stateFile == "" { - // Fallback to cwd - cwd, _ := os.Getwd() - stateFile = filepath.Join(cwd, "state.json") - } - default: - // For other roles, use a generic location - stateFile = filepath.Join(townRoot, ".runtime", "agent-state.json") - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil { - return err - } - - // Read existing state or create new - state := make(map[string]interface{}) - if data, err := os.ReadFile(stateFile); err == nil { - _ = json.Unmarshal(data, &state) - } - - // Set requesting state - state["requesting_"+string(action)] = true - state["requesting_time"] = time.Now().Format(time.RFC3339) - - // Write back - data, err := json.MarshalIndent(state, "", " ") +// getSessionPane returns the pane identifier for a session's main pane. +func getSessionPane(sessionName string) (string, error) { + // Get the pane ID for the first pane in the session + out, err := exec.Command("tmux", "list-panes", "-t", sessionName, "-F", "#{pane_id}").Output() if err != nil { - return err + return "", err } - - return os.WriteFile(stateFile, data, 0644) -} - -// signalDaemon sends SIGUSR1 to the daemon to trigger immediate lifecycle processing. -func signalDaemon(townRoot string) error { - pidFile := filepath.Join(townRoot, "daemon", "daemon.pid") - data, err := os.ReadFile(pidFile) - if err != nil { - return fmt.Errorf("reading daemon PID: %w", err) - } - - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil { - return fmt.Errorf("parsing daemon PID: %w", err) - } - - process, err := os.FindProcess(pid) - if err != nil { - return fmt.Errorf("finding daemon process: %w", err) - } - - if err := process.Signal(syscall.SIGUSR1); err != nil { - return fmt.Errorf("signaling daemon: %w", err) - } - - return nil + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || lines[0] == "" { + return "", fmt.Errorf("no panes found in session") + } + return lines[0], nil } diff --git a/internal/cmd/recycle.go b/internal/cmd/recycle.go deleted file mode 100644 index 2a644961..00000000 --- a/internal/cmd/recycle.go +++ /dev/null @@ -1,241 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strings" - - "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/style" - "github.com/steveyegge/gastown/internal/tmux" -) - -var recycleCmd = &cobra.Command{ - Use: "recycle [role]", - Short: "Hot-reload the current (or specified) agent session", - Long: `Instantly restart an agent session in place. - -This command uses tmux respawn-pane to kill the current session and restart it -with a fresh Claude instance, running the full startup/priming sequence. - -When run without arguments, recycles the current session. -When given a role name, recycles that role's session (and switches to it). - -Examples: - gt recycle # Recycle current session - gt recycle crew # Recycle crew session (auto-detect name) - gt recycle mayor # Recycle mayor session - gt recycle witness # Recycle witness session for current rig - -The command executes instantly - no handoff, no manager involved. -Use 'gt handoff' for graceful lifecycle transitions with context preservation.`, - RunE: runRecycle, -} - -var ( - recycleWatch bool - recycleDryRun bool -) - -func init() { - recycleCmd.Flags().BoolVarP(&recycleWatch, "watch", "w", true, "Switch to recycled session (for remote recycle)") - recycleCmd.Flags().BoolVarP(&recycleDryRun, "dry-run", "n", false, "Show what would be done without executing") - rootCmd.AddCommand(recycleCmd) -} - -func runRecycle(cmd *cobra.Command, args []string) error { - t := tmux.NewTmux() - - // Verify we're in tmux - if !tmux.IsInsideTmux() { - return fmt.Errorf("not running in tmux - cannot recycle") - } - - pane := os.Getenv("TMUX_PANE") - if pane == "" { - return fmt.Errorf("TMUX_PANE not set - cannot recycle") - } - - // Get current session name - currentSession, err := getCurrentTmuxSession() - if err != nil { - return fmt.Errorf("getting session name: %w", err) - } - - // Determine target session - targetSession := currentSession - if len(args) > 0 { - // User specified a role to recycle - targetSession, err = resolveRoleToSession(args[0]) - if err != nil { - return fmt.Errorf("resolving role: %w", err) - } - } - - // Build the restart command - restartCmd, err := buildRestartCommand(targetSession) - if err != nil { - return err - } - - // If recycling a different session, we need to find its pane and respawn there - if targetSession != currentSession { - return recycleRemoteSession(t, targetSession, restartCmd) - } - - // Recycling ourselves - print feedback then respawn - fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), currentSession) - - // Dry run mode - show what would happen - if recycleDryRun { - fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd) - return nil - } - - // Use exec to respawn the pane - this kills us and restarts - return t.RespawnPane(pane, restartCmd) -} - -// getCurrentTmuxSession returns the current tmux session name. -func getCurrentTmuxSession() (string, error) { - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// resolveRoleToSession converts a role name to a tmux session name. -// For roles that need context (crew, witness, refinery), it auto-detects from environment. -func resolveRoleToSession(role string) (string, error) { - switch strings.ToLower(role) { - case "mayor", "may": - return "gt-mayor", nil - - case "deacon", "dea": - return "gt-deacon", nil - - case "crew": - // Try to get rig and crew name from environment or cwd - rig := os.Getenv("GT_RIG") - crewName := os.Getenv("GT_CREW") - if rig == "" || crewName == "" { - // Try to detect from cwd - detected, err := detectCrewFromCwd() - if err == nil { - rig = detected.rigName - crewName = detected.crewName - } - } - if rig == "" || crewName == "" { - return "", fmt.Errorf("cannot determine crew identity - run from crew directory or specify GT_RIG/GT_CREW") - } - return fmt.Sprintf("gt-%s-crew-%s", rig, crewName), nil - - case "witness", "wit": - rig := os.Getenv("GT_RIG") - if rig == "" { - return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") - } - return fmt.Sprintf("gt-%s-witness", rig), nil - - case "refinery", "ref": - rig := os.Getenv("GT_RIG") - if rig == "" { - return "", fmt.Errorf("cannot determine rig - set GT_RIG or run from rig context") - } - return fmt.Sprintf("gt-%s-refinery", rig), nil - - default: - // Assume it's a direct session name - return role, nil - } -} - -// buildRestartCommand creates the gt command to restart a session. -func buildRestartCommand(sessionName string) (string, error) { - switch { - case sessionName == "gt-mayor": - return "gt may at", nil - - case sessionName == "gt-deacon": - return "gt dea at", nil - - case strings.Contains(sessionName, "-crew-"): - // gt--crew- - // The attach command can auto-detect from cwd, so just use `gt crew at` - return "gt crew at", nil - - case strings.HasSuffix(sessionName, "-witness"): - // gt--witness - return "gt wit at", nil - - case strings.HasSuffix(sessionName, "-refinery"): - // gt--refinery - return "gt ref at", nil - - default: - return "", fmt.Errorf("unknown session type: %s (try specifying role explicitly)", sessionName) - } -} - -// recycleRemoteSession respawns a different session and optionally switches to it. -func recycleRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error { - // Check if target session exists - exists, err := t.HasSession(targetSession) - if err != nil { - return fmt.Errorf("checking session: %w", err) - } - if !exists { - return fmt.Errorf("session '%s' not found - is the agent running?", targetSession) - } - - // Get the pane ID for the target session - targetPane, err := getSessionPane(targetSession) - if err != nil { - return fmt.Errorf("getting target pane: %w", err) - } - - fmt.Printf("%s Recycling %s...\n", style.Bold.Render("♻️"), targetSession) - - // Dry run mode - if recycleDryRun { - fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", targetPane, restartCmd) - if recycleWatch { - fmt.Printf("Would execute: tmux switch-client -t %s\n", targetSession) - } - return nil - } - - // Respawn the remote session's pane - if err := t.RespawnPane(targetPane, restartCmd); err != nil { - return fmt.Errorf("respawning pane: %w", err) - } - - // If --watch, switch to that session - if recycleWatch { - fmt.Printf("Switching to %s...\n", targetSession) - // Use tmux switch-client to move our view to the target session - if err := exec.Command("tmux", "switch-client", "-t", targetSession).Run(); err != nil { - // Non-fatal - they can manually switch - fmt.Printf("Note: Could not auto-switch (use: tmux switch-client -t %s)\n", targetSession) - } - } - - return nil -} - -// getSessionPane returns the pane identifier for a session's main pane. -func getSessionPane(sessionName string) (string, error) { - // Get the pane ID for the first pane in the session - out, err := exec.Command("tmux", "list-panes", "-t", sessionName, "-F", "#{pane_id}").Output() - if err != nil { - return "", err - } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) == 0 || lines[0] == "" { - return "", fmt.Errorf("no panes found in session") - } - return lines[0], nil -} diff --git a/internal/cmd/session.go b/internal/cmd/session.go index 654e5c78..37d85870 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -38,7 +38,10 @@ var sessionCmd = &cobra.Command{ Long: `Manage tmux sessions for polecats. Sessions are tmux sessions running Claude for each polecat. -Use the subcommands to start, stop, attach, and monitor sessions.`, +Use the subcommands to start, stop, attach, and monitor sessions. + +TIP: To send messages to a running session, use 'gt nudge' (not 'session inject'). +The nudge command uses reliable delivery that works correctly with Claude Code.`, } var sessionStartCmd = &cobra.Command{ @@ -104,14 +107,19 @@ Examples: var sessionInjectCmd = &cobra.Command{ Use: "inject /", - Short: "Send message to session", + Short: "Send message to session (prefer 'gt nudge')", Long: `Send a message to a polecat session. -Injects text into the session via tmux send-keys. Useful for nudges or notifications. +NOTE: For sending messages to Claude sessions, use 'gt nudge' instead. +It uses reliable delivery (literal mode + timing) that works correctly +with Claude Code's input handling. + +This command is a low-level primitive for file-based injection or +cases where you need raw tmux send-keys behavior. Examples: - gt session inject wyvern/Toast -m "Check your mail" - gt session inject wyvern/Toast -f prompt.txt`, + gt nudge gastown/furiosa "Check your mail" # Preferred + gt session inject wyvern/Toast -f prompt.txt # For file injection`, Args: cobra.ExactArgs(1), RunE: runSessionInject, } diff --git a/prompts/roles/polecat.md b/prompts/roles/polecat.md index c0a4414e..b8d585f3 100644 --- a/prompts/roles/polecat.md +++ b/prompts/roles/polecat.md @@ -90,7 +90,7 @@ gt mail send {{ rig }}/{{ name }} -s "REFRESH: continuing " -m " Completed steps X, Y. Currently on Z. Next: finish Z, then proceed to exit-decision. " -# Then wait for Witness to recycle you +# Then wait for Witness to hand you off ``` The new session picks up where you left off via the molecule state.