fix: Make Mayor/Deacon session names include town name

Session names `gt-mayor` and `gt-deacon` were hardcoded, causing tmux
session name collisions when running multiple towns simultaneously.

Changed to `gt-{town}-mayor` and `gt-{town}-deacon` format (e.g.,
`gt-ai-mayor`) to allow concurrent multi-town operation.

Key changes:
- session.MayorSessionName() and DeaconSessionName() now take townName param
- Added workspace.GetTownName() helper to load town name from config
- Updated all callers in cmd/, daemon/, doctor/, mail/, rig/, templates/
- Updated tests with new session name format
- Bead IDs remain unchanged (already scoped by .beads/ directory)

Fixes #60

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
markov-kernel
2026-01-03 21:21:00 +01:00
parent 7f9795f630
commit e7145cfd77
46 changed files with 4772 additions and 1615 deletions

View File

@@ -14,8 +14,19 @@ import (
"github.com/steveyegge/gastown/internal/workspace"
)
// MayorSessionName is the tmux session name for the Mayor.
const MayorSessionName = "gt-mayor"
// getMayorSessionName returns the Mayor session name for the current workspace.
// The session name includes the town name to avoid collisions between multiple HQs.
func getMayorSessionName() (string, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return "", fmt.Errorf("not in a Gas Town workspace: %w", err)
}
townName, err := workspace.GetTownName(townRoot)
if err != nil {
return "", err
}
return session.MayorSessionName(townName), nil
}
var mayorCmd = &cobra.Command{
Use: "mayor",
@@ -88,8 +99,13 @@ func init() {
func runMayorStart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName, err := getMayorSessionName()
if err != nil {
return err
}
// Check if session already exists
running, err := t.HasSession(MayorSessionName)
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
@@ -97,7 +113,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
}
if err := startMayorSession(t); err != nil {
if err := startMayorSession(t, sessionName); err != nil {
return err
}
@@ -109,7 +125,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
}
// startMayorSession creates and initializes the Mayor tmux session.
func startMayorSession(t *tmux.Tmux) error {
func startMayorSession(t *tmux.Tmux, sessionName string) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
@@ -118,36 +134,36 @@ func startMayorSession(t *tmux.Tmux) error {
// Create session in workspace root
fmt.Println("Starting Mayor session...")
if err := t.NewSession(MayorSessionName, townRoot); err != nil {
if err := t.NewSession(sessionName, townRoot); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
_ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor")
_ = t.SetEnvironment(sessionName, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "mayor")
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
// Note: ConfigureGasTownSession includes cycle bindings
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator")
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// Use SendKeysDelayed to allow shell initialization after NewSession
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "")
if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil {
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return fmt.Errorf("sending command: %w", err)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(MayorSessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, MayorSessionName, session.StartupNudgeConfig{
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: "mayor",
Sender: "human",
Topic: "cold-start",
@@ -157,7 +173,7 @@ func startMayorSession(t *tmux.Tmux) error {
// Send the propulsion nudge to trigger autonomous coordination.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(MayorSessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal
return nil
}
@@ -165,8 +181,13 @@ func startMayorSession(t *tmux.Tmux) error {
func runMayorStop(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName, err := getMayorSessionName()
if err != nil {
return err
}
// Check if session exists
running, err := t.HasSession(MayorSessionName)
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
@@ -177,11 +198,11 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping Mayor session...")
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(MayorSessionName, "C-c")
_ = t.SendKeysRaw(sessionName, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(MayorSessionName); err != nil {
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
@@ -192,34 +213,44 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
func runMayorAttach(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName, err := getMayorSessionName()
if err != nil {
return err
}
// Check if session exists
running, err := t.HasSession(MayorSessionName)
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
// Auto-start if not running
fmt.Println("Mayor session not running, starting...")
if err := startMayorSession(t); err != nil {
if err := startMayorSession(t, sessionName); err != nil {
return err
}
}
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
return attachToTmuxSession(MayorSessionName)
return attachToTmuxSession(sessionName)
}
func runMayorStatus(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
running, err := t.HasSession(MayorSessionName)
sessionName, err := getMayorSessionName()
if err != nil {
return err
}
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
// Get session info for more details
info, err := t.GetSessionInfo(MayorSessionName)
info, err := t.GetSessionInfo(sessionName)
if err == nil {
status := "detached"
if info.Attached {
@@ -249,7 +280,12 @@ func runMayorStatus(cmd *cobra.Command, args []string) error {
func runMayorRestart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
running, err := t.HasSession(MayorSessionName)
sessionName, err := getMayorSessionName()
if err != nil {
return err
}
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
@@ -257,9 +293,9 @@ func runMayorRestart(cmd *cobra.Command, args []string) error {
if running {
// Stop the current session (best-effort interrupt before kill)
fmt.Println("Stopping Mayor session...")
_ = t.SendKeysRaw(MayorSessionName, "C-c")
_ = t.SendKeysRaw(sessionName, "C-c")
time.Sleep(100 * time.Millisecond)
if err := t.KillSession(MayorSessionName); err != nil {
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
}