Files
gastown/internal/cmd/handoff.go
furiosa bd0f30cfdd fix(handoff): don't kill pane processes before respawn (hq-bv7ef)
The previous approach using KillPaneProcessesExcluding/KillPaneProcesses
killed the pane's main process (Claude/node) before calling RespawnPane.
This caused the pane to close (since tmux's remain-on-exit is off by default),
which then made RespawnPane fail because the target pane no longer exists.

The respawn-pane -k flag handles killing atomically - it kills the old process
and starts the new one in a single operation without closing the pane in between.
If orphan processes remain (e.g., Claude ignoring SIGHUP), they will be cleaned
up when the new session starts or by periodic cleanup processes.

This fixes both self-handoff and remote handoff paths.

Fixes: hq-bv7ef

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

826 lines
28 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
var handoffCmd = &cobra.Command{
Use: "handoff [bead-or-role]",
GroupID: GroupWork,
Short: "Hand off to a fresh session, work continues from hook",
Long: `End watch. Hand off to a fresh agent session.
This is the canonical way to end any agent session. It handles all roles:
- Mayor, Crew, Witness, Refinery, Deacon: Respawns with fresh Claude instance
- Polecats: Calls 'gt done --status DEFERRED' (Witness handles lifecycle)
When run without arguments, hands off the current session.
When given a bead ID (gt-xxx, hq-xxx), hooks that work first, then restarts.
When given a role name, hands off that role's session (and switches to it).
Examples:
gt handoff # Hand off current session
gt handoff gt-abc # Hook bead, then restart
gt handoff gt-abc -s "Fix it" # Hook with context, then restart
gt handoff -s "Context" -m "Notes" # Hand off with custom message
gt handoff -c # Collect state into handoff message
gt handoff crew # Hand off crew session
gt handoff mayor # Hand off mayor session
The --collect (-c) flag gathers current state (hooked work, inbox, ready beads,
in-progress items) and includes it in the handoff mail. This provides context
for the next session without manual summarization.
Any molecule on the hook will be auto-continued by the new session.
The SessionStart hook runs 'gt prime' to restore context.`,
RunE: runHandoff,
}
var (
handoffWatch bool
handoffDryRun bool
handoffSubject string
handoffMessage string
handoffCollect bool
)
func init() {
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")
handoffCmd.Flags().StringVarP(&handoffSubject, "subject", "s", "", "Subject for handoff mail (optional)")
handoffCmd.Flags().StringVarP(&handoffMessage, "message", "m", "", "Message body for handoff mail (optional)")
handoffCmd.Flags().BoolVarP(&handoffCollect, "collect", "c", false, "Auto-collect state (status, inbox, beads) into handoff message")
rootCmd.AddCommand(handoffCmd)
}
func runHandoff(cmd *cobra.Command, args []string) error {
// Check if we're a polecat - polecats use gt done instead
// GT_POLECAT is set by the session manager when starting polecat sessions
if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" {
fmt.Printf("%s Polecat detected (%s) - using gt done for handoff\n",
style.Bold.Render("🐾"), polecatName)
// Polecats don't respawn themselves - Witness handles lifecycle
// Call gt done with DEFERRED exit type to preserve work state
doneCmd := exec.Command("gt", "done", "--exit", "DEFERRED")
doneCmd.Stdout = os.Stdout
doneCmd.Stderr = os.Stderr
return doneCmd.Run()
}
// If --collect flag is set, auto-collect state into the message
if handoffCollect {
collected := collectHandoffState()
if handoffMessage == "" {
handoffMessage = collected
} else {
handoffMessage = handoffMessage + "\n\n---\n" + collected
}
if handoffSubject == "" {
handoffSubject = "Session handoff with context"
}
}
t := tmux.NewTmux()
// Verify we're in tmux
if !tmux.IsInsideTmux() {
return fmt.Errorf("not running in tmux - cannot hand off")
}
pane := os.Getenv("TMUX_PANE")
if pane == "" {
return fmt.Errorf("TMUX_PANE not set - cannot hand off")
}
// Get current session name
currentSession, err := getCurrentTmuxSession()
if err != nil {
return fmt.Errorf("getting session name: %w", err)
}
// Determine target session and check for bead hook
targetSession := currentSession
if len(args) > 0 {
arg := args[0]
// Check if arg is a bead ID (gt-xxx, hq-xxx, bd-xxx, etc.)
if looksLikeBeadID(arg) {
// Hook the bead first
if err := hookBeadForHandoff(arg); err != nil {
return fmt.Errorf("hooking bead: %w", err)
}
// Update subject if not set
if handoffSubject == "" {
handoffSubject = fmt.Sprintf("🪝 HOOKED: %s", arg)
}
} else {
// User specified a role to hand off
targetSession, err = resolveRoleToSession(arg)
if err != nil {
return fmt.Errorf("resolving role: %w", err)
}
}
}
// Build the restart command
restartCmd, err := buildRestartCommand(targetSession)
if err != nil {
return err
}
// If handing off a different session, we need to find its pane and respawn there
if targetSession != currentSession {
return handoffRemoteSession(t, targetSession, restartCmd)
}
// Handing off ourselves - print feedback then respawn
fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), currentSession)
// Log handoff event (both townlog and events feed)
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := sessionToGTRole(currentSession)
if agent == "" {
agent = currentSession
}
_ = LogHandoff(townRoot, agent, handoffSubject)
// Also log to activity feed
_ = events.LogFeed(events.TypeHandoff, agent, events.HandoffPayload(handoffSubject, true))
}
// Dry run mode - show what would happen (BEFORE any side effects)
if handoffDryRun {
if handoffSubject != "" || handoffMessage != "" {
fmt.Printf("Would send handoff mail: subject=%q (auto-hooked)\n", handoffSubject)
}
fmt.Printf("Would execute: tmux clear-history -t %s\n", pane)
fmt.Printf("Would execute: tmux respawn-pane -k -t %s %s\n", pane, restartCmd)
return nil
}
// If subject/message provided, send handoff mail to self first
// The mail is auto-hooked so the next session picks it up
if handoffSubject != "" || handoffMessage != "" {
beadID, err := sendHandoffMail(handoffSubject, handoffMessage)
if err != nil {
style.PrintWarning("could not send handoff mail: %v", err)
// Continue anyway - the respawn is more important
} else {
fmt.Printf("%s Sent handoff mail %s (auto-hooked)\n", style.Bold.Render("📬"), beadID)
}
}
// NOTE: reportAgentState("stopped") removed (gt-zecmc)
// Agent liveness is observable from tmux - no need to record it in bead.
// "Discover, don't track" principle: reality is truth, state is derived.
// Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0])
if err := t.ClearHistory(pane); err != nil {
// Non-fatal - continue with respawn even if clear fails
style.PrintWarning("could not clear history: %v", err)
}
// Write handoff marker for successor detection (prevents handoff loop bug).
// The marker is cleared by gt prime after it outputs the warning.
// This tells the new session "you're post-handoff, don't re-run /handoff"
if cwd, err := os.Getwd(); err == nil {
runtimeDir := filepath.Join(cwd, constants.DirRuntime)
_ = os.MkdirAll(runtimeDir, 0755)
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
_ = os.WriteFile(markerPath, []byte(currentSession), 0644)
}
// NOTE: We intentionally do NOT kill pane processes before respawning (hq-bv7ef).
// Previous approach (KillPaneProcessesExcluding) killed the pane's main process,
// which caused the pane to close (remain-on-exit is off by default), making
// RespawnPane fail because the target pane no longer exists.
//
// The respawn-pane -k flag handles killing atomically - it kills the old process
// and starts the new one in a single operation without closing the pane.
// If orphan processes remain (e.g., Claude ignoring SIGHUP), they will be cleaned
// up when the new session starts or when the Witness runs periodic cleanup.
// Use respawn-pane to atomically kill old process and start new one
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 or path to a tmux session name.
// Accepts:
// - Role shortcuts: "crew", "witness", "refinery", "mayor", "deacon"
// - Full paths: "<rig>/crew/<name>", "<rig>/witness", "<rig>/refinery"
// - Direct session names (passed through)
//
// For role shortcuts that need context (crew, witness, refinery), it auto-detects from environment.
func resolveRoleToSession(role string) (string, error) {
// First, check if it's a path format (contains /)
if strings.Contains(role, "/") {
return resolvePathToSession(role)
}
switch strings.ToLower(role) {
case "mayor", "may":
return getMayorSessionName(), nil
case "deacon", "dea":
return getDeaconSessionName(), 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 (e.g., gt-gastown-crew-max)
return role, nil
}
}
// resolvePathToSession converts a path like "<rig>/crew/<name>" to a session name.
// Supported formats:
// - <rig>/crew/<name> -> gt-<rig>-crew-<name>
// - <rig>/witness -> gt-<rig>-witness
// - <rig>/refinery -> gt-<rig>-refinery
// - <rig>/polecats/<name> -> gt-<rig>-<name> (explicit polecat)
// - <rig>/<name> -> gt-<rig>-<name> (polecat shorthand, if name isn't a known role)
func resolvePathToSession(path string) (string, error) {
parts := strings.Split(path, "/")
// Handle <rig>/crew/<name> format
if len(parts) == 3 && parts[1] == "crew" {
rig := parts[0]
name := parts[2]
return fmt.Sprintf("gt-%s-crew-%s", rig, name), nil
}
// Handle <rig>/polecats/<name> format (explicit polecat path)
if len(parts) == 3 && parts[1] == "polecats" {
rig := parts[0]
name := strings.ToLower(parts[2]) // normalize polecat name
return fmt.Sprintf("gt-%s-%s", rig, name), nil
}
// Handle <rig>/<role-or-polecat> format
if len(parts) == 2 {
rig := parts[0]
second := parts[1]
secondLower := strings.ToLower(second)
// Check for known roles first
switch secondLower {
case "witness":
return fmt.Sprintf("gt-%s-witness", rig), nil
case "refinery":
return fmt.Sprintf("gt-%s-refinery", rig), nil
case "crew":
// Just "<rig>/crew" without a name - need more info
return "", fmt.Errorf("crew path requires name: %s/crew/<name>", rig)
case "polecats":
// Just "<rig>/polecats" without a name - need more info
return "", fmt.Errorf("polecats path requires name: %s/polecats/<name>", rig)
default:
// Not a known role - check if it's a crew member before assuming polecat.
// Crew members exist at <townRoot>/<rig>/crew/<name>.
// This fixes: gt sling gt-375 gastown/max failing because max is crew, not polecat.
townRoot := detectTownRootFromCwd()
if townRoot != "" {
crewPath := filepath.Join(townRoot, rig, "crew", second)
if info, err := os.Stat(crewPath); err == nil && info.IsDir() {
return fmt.Sprintf("gt-%s-crew-%s", rig, second), nil
}
}
// Not a crew member - treat as polecat name (e.g., gastown/nux)
return fmt.Sprintf("gt-%s-%s", rig, secondLower), nil
}
}
return "", fmt.Errorf("cannot parse path '%s' - expected <rig>/<polecat>, <rig>/crew/<name>, <rig>/witness, or <rig>/refinery", path)
}
// claudeEnvVars lists the Claude-related environment variables to propagate
// during handoff. These vars aren't inherited by tmux respawn-pane's fresh shell.
var claudeEnvVars = []string{
// Claude API and config
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_USE_BEDROCK",
// AWS vars for Bedrock
"AWS_PROFILE",
"AWS_REGION",
}
// buildRestartCommand creates the command to run when respawning a session's pane.
// This needs to be the actual command to execute (e.g., claude), not a session attach command.
// The command includes a cd to the correct working directory for the role.
func buildRestartCommand(sessionName string) (string, error) {
// Detect town root from current directory
townRoot := detectTownRootFromCwd()
if townRoot == "" {
return "", fmt.Errorf("cannot detect town root - run from within a Gas Town workspace")
}
// Determine the working directory for this session type
workDir, err := sessionWorkDir(sessionName, townRoot)
if err != nil {
return "", err
}
// Parse the session name to get the identity (used for GT_ROLE and beacon)
identity, err := session.ParseSessionName(sessionName)
if err != nil {
return "", fmt.Errorf("cannot parse session name %q: %w", sessionName, err)
}
gtRole := identity.GTRole()
// 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)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
Recipient: identity.Address(),
Sender: "self",
Topic: "handoff",
})
// For respawn-pane, we:
// 1. cd to the right directory (role's canonical home)
// 2. export GT_ROLE and BD_ACTOR so role detection works correctly
// 3. export Claude-related env vars (not inherited by fresh shell)
// 4. run claude with the startup beacon (triggers immediate context loading)
// Use exec to ensure clean process replacement.
//
// Check if current session is using a non-default agent (GT_AGENT env var).
// If so, preserve it across handoff by using the override variant.
currentAgent := os.Getenv("GT_AGENT")
var runtimeCmd string
if currentAgent != "" {
var err error
runtimeCmd, err = config.GetRuntimeCommandWithPromptAndAgentOverride("", beacon, currentAgent)
if err != nil {
return "", fmt.Errorf("resolving agent config: %w", err)
}
} else {
runtimeCmd = config.GetRuntimeCommandWithPrompt("", beacon)
}
// Build environment exports - role vars first, then Claude vars
var exports []string
if gtRole != "" {
runtimeConfig := config.LoadRuntimeConfig("")
exports = append(exports, "GT_ROLE="+gtRole)
exports = append(exports, "BD_ACTOR="+gtRole)
exports = append(exports, "GIT_AUTHOR_NAME="+gtRole)
if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
exports = append(exports, "GT_SESSION_ID_ENV="+runtimeConfig.Session.SessionIDEnv)
}
}
// Propagate GT_ROOT so subsequent handoffs can use it as fallback
// when cwd-based detection fails (broken state recovery)
exports = append(exports, "GT_ROOT="+townRoot)
// Preserve GT_AGENT across handoff so agent override persists
if currentAgent != "" {
exports = append(exports, "GT_AGENT="+currentAgent)
}
// Add Claude-related env vars from current environment
for _, name := range claudeEnvVars {
if val := os.Getenv(name); val != "" {
// Shell-escape the value in case it contains special chars
exports = append(exports, fmt.Sprintf("%s=%q", name, val))
}
}
if len(exports) > 0 {
return fmt.Sprintf("cd %s && export %s && exec %s", workDir, strings.Join(exports, " "), runtimeCmd), nil
}
return fmt.Sprintf("cd %s && exec %s", workDir, runtimeCmd), nil
}
// sessionWorkDir returns the correct working directory for a session.
// This is the canonical home for each role type.
func sessionWorkDir(sessionName, townRoot string) (string, error) {
// Get session names for comparison
mayorSession := getMayorSessionName()
deaconSession := getDeaconSessionName()
switch {
case sessionName == mayorSession:
return townRoot, nil
case sessionName == deaconSession:
return townRoot + "/deacon", nil
case strings.Contains(sessionName, "-crew-"):
// gt-<rig>-crew-<name> -> <townRoot>/<rig>/crew/<name>
parts := strings.Split(sessionName, "-")
if len(parts) < 4 {
return "", fmt.Errorf("invalid crew session name: %s", sessionName)
}
// Find the index of "crew" to split rig name (may contain dashes)
for i, p := range parts {
if p == "crew" && i > 1 && i < len(parts)-1 {
rig := strings.Join(parts[1:i], "-")
name := strings.Join(parts[i+1:], "-")
return fmt.Sprintf("%s/%s/crew/%s", townRoot, rig, name), nil
}
}
return "", fmt.Errorf("cannot parse crew session name: %s", sessionName)
case strings.HasSuffix(sessionName, "-witness"):
// gt-<rig>-witness -> <townRoot>/<rig>/witness
// Note: witness doesn't have a /rig worktree like refinery does
rig := strings.TrimPrefix(sessionName, "gt-")
rig = strings.TrimSuffix(rig, "-witness")
return fmt.Sprintf("%s/%s/witness", townRoot, rig), nil
case strings.HasSuffix(sessionName, "-refinery"):
// gt-<rig>-refinery -> <townRoot>/<rig>/refinery/rig
rig := strings.TrimPrefix(sessionName, "gt-")
rig = strings.TrimSuffix(rig, "-refinery")
return fmt.Sprintf("%s/%s/refinery/rig", townRoot, rig), nil
default:
// Assume polecat: gt-<rig>-<name> -> <townRoot>/<rig>/polecats/<name>
// Use session.ParseSessionName to determine rig and name
identity, err := session.ParseSessionName(sessionName)
if err != nil {
return "", fmt.Errorf("unknown session type: %s (%w)", sessionName, err)
}
if identity.Role != session.RolePolecat {
return "", fmt.Errorf("unknown session type: %s (role %s, try specifying role explicitly)", sessionName, identity.Role)
}
return fmt.Sprintf("%s/%s/polecats/%s", townRoot, identity.Rig, identity.Name), nil
}
}
// sessionToGTRole converts a session name to a GT_ROLE value.
// Uses session.ParseSessionName for consistent parsing across the codebase.
func sessionToGTRole(sessionName string) string {
identity, err := session.ParseSessionName(sessionName)
if err != nil {
return ""
}
return identity.GTRole()
}
// detectTownRootFromCwd walks up from the current directory to find the town root.
// Falls back to GT_TOWN_ROOT or GT_ROOT env vars if cwd detection fails (broken state recovery).
func detectTownRootFromCwd() string {
// Use workspace.FindFromCwd which handles both primary (mayor/town.json)
// and secondary (mayor/ directory) markers
townRoot, err := workspace.FindFromCwd()
if err == nil && townRoot != "" {
return townRoot
}
// Fallback: try environment variables for town root
// GT_TOWN_ROOT is set by shell integration, GT_ROOT is set by session manager
// This enables handoff to work even when cwd detection fails due to
// detached HEAD, wrong branch, deleted worktree, etc.
for _, envName := range []string{"GT_TOWN_ROOT", "GT_ROOT"} {
if envRoot := os.Getenv(envName); envRoot != "" {
// Verify it's actually a workspace
if _, statErr := os.Stat(filepath.Join(envRoot, workspace.PrimaryMarker)); statErr == nil {
return envRoot
}
// Try secondary marker too
if info, statErr := os.Stat(filepath.Join(envRoot, workspace.SecondaryMarker)); statErr == nil && info.IsDir() {
return envRoot
}
}
}
return ""
}
// 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 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 Handing off %s...\n", style.Bold.Render("🤝"), targetSession)
// Dry run mode
if handoffDryRun {
fmt.Printf("Would execute: tmux clear-history -t %s\n", targetPane)
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 nil
}
// NOTE: We intentionally do NOT kill pane processes before respawning (hq-bv7ef).
// Previous approach (KillPaneProcesses) killed the pane's main process, which caused
// the pane to close (remain-on-exit is off by default), making RespawnPane fail.
// The respawn-pane -k flag handles killing atomically without closing the pane.
// Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0])
if err := t.ClearHistory(targetPane); err != nil {
// Non-fatal - continue with respawn even if clear fails
style.PrintWarning("could not clear history: %v", err)
}
// Respawn the remote session's pane - -k flag atomically kills old process and starts new one
if err := t.RespawnPane(targetPane, restartCmd); err != nil {
return fmt.Errorf("respawning pane: %w", err)
}
// 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
}
// 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
}
// sendHandoffMail sends a handoff mail to self and auto-hooks it.
// Returns the created bead ID and any error.
func sendHandoffMail(subject, message string) (string, error) {
// Build subject with handoff prefix if not already present
if subject == "" {
subject = "🤝 HANDOFF: Session cycling"
} else if !strings.Contains(subject, "HANDOFF") {
subject = "🤝 HANDOFF: " + subject
}
// Default message if not provided
if message == "" {
message = "Context cycling. Check bd ready for pending work."
}
// Detect agent identity for self-mail
agentID, _, _, err := resolveSelfTarget()
if err != nil {
return "", fmt.Errorf("detecting agent identity: %w", err)
}
// Normalize identity to match mailbox query format
agentID = mail.AddressToIdentity(agentID)
// Detect town root for beads location
townRoot := detectTownRootFromCwd()
if townRoot == "" {
return "", fmt.Errorf("cannot detect town root")
}
// Build labels for mail metadata (matches mail router format)
labels := fmt.Sprintf("from:%s", agentID)
// Create mail bead directly using bd create with --silent to get the ID
// Mail goes to town-level beads (hq- prefix)
args := []string{
"create", subject,
"--type", "message",
"--assignee", agentID,
"-d", message,
"--priority", "2",
"--labels", labels,
"--actor", agentID,
"--ephemeral", // Handoff mail is ephemeral
"--silent", // Output only the bead ID
}
cmd := exec.Command("bd", args...)
cmd.Dir = townRoot // Run from town root for town-level beads
cmd.Env = append(os.Environ(), "BEADS_DIR="+filepath.Join(townRoot, ".beads"))
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("creating handoff mail: %s", errMsg)
}
return "", fmt.Errorf("creating handoff mail: %w", err)
}
beadID := strings.TrimSpace(stdout.String())
if beadID == "" {
return "", fmt.Errorf("bd create did not return bead ID")
}
// Auto-hook the created mail bead
hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+agentID)
hookCmd.Dir = townRoot
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+filepath.Join(townRoot, ".beads"))
hookCmd.Stderr = os.Stderr
if err := hookCmd.Run(); err != nil {
// Non-fatal: mail was created, just couldn't hook
style.PrintWarning("created mail %s but failed to auto-hook: %v", beadID, err)
return beadID, nil
}
return beadID, nil
}
// looksLikeBeadID checks if a string looks like a bead ID.
// Bead IDs have format: prefix-xxxx where prefix is 1-5 lowercase letters and xxxx is alphanumeric.
// Examples: "gt-abc123", "bd-ka761", "hq-cv-abc", "beads-xyz", "ap-qtsup.16"
func looksLikeBeadID(s string) bool {
// Find the first hyphen
idx := strings.Index(s, "-")
if idx < 1 || idx > 5 {
// No hyphen, or prefix is empty/too long
return false
}
// Check prefix is all lowercase letters
prefix := s[:idx]
for _, c := range prefix {
if c < 'a' || c > 'z' {
return false
}
}
// Check there's something after the hyphen
rest := s[idx+1:]
if len(rest) == 0 {
return false
}
// Check rest starts with alphanumeric and contains only alphanumeric, dots, hyphens
first := rest[0]
if !((first >= 'a' && first <= 'z') || (first >= '0' && first <= '9')) {
return false
}
return true
}
// hookBeadForHandoff attaches a bead to the current agent's hook.
func hookBeadForHandoff(beadID string) error {
// Verify the bead exists first
verifyCmd := exec.Command("bd", "show", beadID, "--json")
if err := verifyCmd.Run(); err != nil {
return fmt.Errorf("bead '%s' not found", beadID)
}
// Determine agent identity
agentID, _, _, err := resolveSelfTarget()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
fmt.Printf("%s Hooking %s...\n", style.Bold.Render("🪝"), beadID)
if handoffDryRun {
fmt.Printf("Would run: bd update %s --status=pinned --assignee=%s\n", beadID, agentID)
return nil
}
// Pin the bead using bd update (discovery-based approach)
pinCmd := exec.Command("bd", "update", beadID, "--status=pinned", "--assignee="+agentID)
pinCmd.Stderr = os.Stderr
if err := pinCmd.Run(); err != nil {
return fmt.Errorf("pinning bead: %w", err)
}
fmt.Printf("%s Work attached to hook (pinned bead)\n", style.Bold.Render("✓"))
return nil
}
// collectHandoffState gathers current state for handoff context.
// Collects: inbox summary, ready beads, hooked work.
func collectHandoffState() string {
var parts []string
// Get hooked work
hookOutput, err := exec.Command("gt", "hook").Output()
if err == nil {
hookStr := strings.TrimSpace(string(hookOutput))
if hookStr != "" && !strings.Contains(hookStr, "Nothing on hook") {
parts = append(parts, "## Hooked Work\n"+hookStr)
}
}
// Get inbox summary (first few messages)
inboxOutput, err := exec.Command("gt", "mail", "inbox").Output()
if err == nil {
inboxStr := strings.TrimSpace(string(inboxOutput))
if inboxStr != "" && !strings.Contains(inboxStr, "Inbox empty") {
// Limit to first 10 lines for brevity
lines := strings.Split(inboxStr, "\n")
if len(lines) > 10 {
lines = append(lines[:10], "... (more messages)")
}
parts = append(parts, "## Inbox\n"+strings.Join(lines, "\n"))
}
}
// Get ready beads
readyOutput, err := exec.Command("bd", "ready").Output()
if err == nil {
readyStr := strings.TrimSpace(string(readyOutput))
if readyStr != "" && !strings.Contains(readyStr, "No issues ready") {
// Limit to first 10 lines
lines := strings.Split(readyStr, "\n")
if len(lines) > 10 {
lines = append(lines[:10], "... (more issues)")
}
parts = append(parts, "## Ready Work\n"+strings.Join(lines, "\n"))
}
}
// Get in-progress beads
inProgressOutput, err := exec.Command("bd", "list", "--status=in_progress").Output()
if err == nil {
ipStr := strings.TrimSpace(string(inProgressOutput))
if ipStr != "" && !strings.Contains(ipStr, "No issues") {
lines := strings.Split(ipStr, "\n")
if len(lines) > 5 {
lines = append(lines[:5], "... (more)")
}
parts = append(parts, "## In Progress\n"+strings.Join(lines, "\n"))
}
}
if len(parts) == 0 {
return "No active state to report."
}
return strings.Join(parts, "\n\n")
}