From 66042da18bbe728beaea145f518a75f039743410 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 18:01:47 -0800 Subject: [PATCH] Add session beacon for predecessor discovery via /resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject an identity beacon as the first message when Gas Town starts Claude sessions. This beacon becomes the session title in Claude Code '/resume picker, enabling workers to find their predecessor sessions for debugging. Beacon format: [GAS TOWN]
Examples: - [GAS TOWN] gastown/crew/max • gt-abc12 • 2025-12-30T14:32 - [GAS TOWN] gastown/polecats/Toast • ready • 2025-12-30T09:15 - [GAS TOWN] deacon • patrol • 2025-12-30T08:00 Workers can now press / in /resume picker and search for their address (e.g., "gastown/crew/max") to see all predecessor sessions. Note: Respawn-loop agents (deacon/refinery via up.go) skip beacon injection since Claude restarts multiple times - would need post-restart injection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_lifecycle.go | 27 +++++++++++++++++++ internal/cmd/deacon.go | 12 +++++++++ internal/cmd/mayor.go | 12 +++++++++ internal/cmd/start.go | 13 +++++++++ internal/cmd/up.go | 49 ++++++++++++++++++++++++++++++++++ internal/cmd/witness.go | 14 ++++++++++ internal/session/manager.go | 16 +++++++++++ internal/session/names.go | 24 ++++++++++++++++- 8 files changed, 166 insertions(+), 1 deletion(-) diff --git a/internal/cmd/crew_lifecycle.go b/internal/cmd/crew_lifecycle.go index 82841131..5df46ccd 100644 --- a/internal/cmd/crew_lifecycle.go +++ b/internal/cmd/crew_lifecycle.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) @@ -181,6 +182,18 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error { return fmt.Errorf("starting claude: %w", err) } + // Wait for Claude to start + shells := constants.SupportedShells + if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", r.Name, name) + beacon := session.SessionBeacon(address, "") + _ = t.NudgeSession(sessionID, beacon) // Non-fatal + fmt.Printf("%s Refreshed crew workspace: %s/%s\n", style.Bold.Render("✓"), r.Name, name) fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name))) @@ -330,6 +343,14 @@ func runCrewRestart(cmd *cobra.Command, args []string) error { } // Give Claude time to initialize after process starts time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", r.Name, name) + beacon := session.SessionBeacon(address, "") + if err := t.NudgeSession(sessionID, beacon); err != nil { + // Non-fatal: session works without beacon + } + if err := t.SendKeys(sessionID, "gt prime"); err != nil { // Non-fatal: Claude started but priming failed style.PrintWarning("Could not send prime command to %s: %v", arg, err) @@ -495,6 +516,12 @@ func restartCrewSession(rigName, crewName, clonePath string) error { // Non-fatal warning } time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", rigName, crewName) + beacon := session.SessionBeacon(address, "") + _ = t.NudgeSession(sessionID, beacon) // Non-fatal + if err := t.SendKeys(sessionID, "gt prime"); err != nil { // Non-fatal } diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 9edc78df..9c6f0a83 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -9,8 +9,10 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/polecat" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -197,6 +199,16 @@ func startDeaconSession(t *tmux.Tmux) error { return fmt.Errorf("sending command: %w", err) } + // Wait for Claude to start (non-fatal) + if err := t.WaitForCommand(DeaconSessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + beacon := session.SessionBeacon("deacon", "patrol") + _ = t.NudgeSession(DeaconSessionName, beacon) // Non-fatal + return nil } diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index dd2b6942..dff67c82 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -6,6 +6,8 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/constants" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -135,6 +137,16 @@ func startMayorSession(t *tmux.Tmux) error { 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 { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + beacon := session.SessionBeacon("mayor", "") + _ = t.NudgeSession(MayorSessionName, beacon) // Non-fatal + return nil } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 20dd38e7..74de869b 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -16,6 +16,7 @@ import ( "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -797,6 +798,13 @@ func runStartCrew(cmd *cobra.Command, args []string) error { // Give Claude time to initialize after process starts time.Sleep(constants.ShutdownNotifyDelay) + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", rigName, name) + beacon := session.SessionBeacon(address, "") + if err := t.NudgeSession(sessionID, beacon); err != nil { + // Non-fatal: session works without beacon + } + // Send gt prime to initialize context if err := t.SendKeys(sessionID, "gt prime"); err != nil { style.PrintWarning("Could not send prime command: %v", err) @@ -931,6 +939,11 @@ func startCrewMember(rigName, crewName, townRoot string) error { // Give Claude time to initialize time.Sleep(constants.ShutdownNotifyDelay) + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", rigName, crewName) + beacon := session.SessionBeacon(address, "") + _ = t.NudgeSession(sessionID, beacon) // Non-fatal + // Send gt prime to initialize context (non-fatal: session works without priming) _ = t.SendKeys(sessionID, "gt prime") diff --git a/internal/cmd/up.go b/internal/cmd/up.go index 162b2cad..ea78aa49 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -11,9 +11,11 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/daemon" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/refinery" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -271,6 +273,20 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { return err } + // Wait for Claude to start (non-fatal) + // Note: Deacon respawn loop makes beacon tricky - Claude restarts multiple times + // For non-respawn (mayor), inject beacon + if role != "deacon" { + if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + beacon := session.SessionBeacon(role, "") + _ = t.NudgeSession(sessionName, beacon) // Non-fatal + } + return nil } @@ -306,6 +322,17 @@ func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error { return err } + // Wait for Claude to start (non-fatal) + if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/witness", rigName) + beacon := session.SessionBeacon(address, "patrol") + _ = t.NudgeSession(sessionName, beacon) // Non-fatal + return nil } @@ -517,6 +544,17 @@ func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName st return err } + // Wait for Claude to start (non-fatal) + if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/crew/%s", rigName, crewName) + beacon := session.SessionBeacon(address, "") + _ = t.NudgeSession(sessionName, beacon) // Non-fatal + return nil } @@ -605,5 +643,16 @@ func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polec return err } + // Wait for Claude to start (non-fatal) + if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) + beacon := session.SessionBeacon(address, "") + _ = t.NudgeSession(sessionName, beacon) // Non-fatal + return nil } diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index 0fd8083c..5cc392ad 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -6,10 +6,13 @@ import ( "os" "os/exec" "path/filepath" + "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/witness" @@ -333,6 +336,17 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { return false, fmt.Errorf("sending command: %w", err) } + // Wait for Claude to start (non-fatal) + if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + address := fmt.Sprintf("%s/witness", rigName) + beacon := session.SessionBeacon(address, "patrol") + _ = t.NudgeSession(sessionName, beacon) // Non-fatal + return true, nil } diff --git a/internal/session/manager.go b/internal/session/manager.go index 6ac11cf1..497f4055 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -11,6 +11,7 @@ import ( "time" "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" ) @@ -185,6 +186,21 @@ func (m *Manager) Start(polecat string, opts StartOptions) error { return fmt.Errorf("sending command: %w", err) } + // Wait for Claude to start (non-fatal: session continues even if this times out) + if err := m.tmux.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { + // Non-fatal warning - Claude might still start + } + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject session beacon for predecessor discovery via /resume + // This becomes the session title in Claude Code's session picker + address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat) + molID := opts.Issue // Use issue ID if provided + beacon := SessionBeacon(address, molID) + if err := m.tmux.NudgeSession(sessionID, beacon); err != nil { + // Non-fatal: session works without beacon + } + return nil } diff --git a/internal/session/names.go b/internal/session/names.go index 303bdbaa..ecedf91c 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -1,7 +1,10 @@ // Package session provides polecat session lifecycle management. package session -import "fmt" +import ( + "fmt" + "time" +) // Prefix is the common prefix for all Gas Town tmux session names. const Prefix = "gt-" @@ -35,3 +38,22 @@ func CrewSessionName(rig, name string) string { func PolecatSessionName(rig, name string) string { return fmt.Sprintf("%s%s-%s", Prefix, rig, name) } + +// SessionBeacon generates an identity beacon message for Claude Code sessions. +// This beacon becomes the session title in /resume picker, enabling workers to +// find their predecessor sessions. +// +// Format: [GAS TOWN]
+// +// Examples: +// - [GAS TOWN] gastown/crew/max • gt-abc12 • 2025-12-30T14:32 +// - [GAS TOWN] gastown/polecats/Toast • ready • 2025-12-30T09:15 +// - [GAS TOWN] deacon • patrol • 2025-12-30T08:00 +func SessionBeacon(address, molID string) string { + if molID == "" { + molID = "ready" + } + // Use local time in a compact format + timestamp := time.Now().Format("2006-01-02T15:04") + return fmt.Sprintf("[GAS TOWN] %s • %s • %s", address, molID, timestamp) +}