From 31bd120077fb24e36f6407b7efdb13d14ca59b77 Mon Sep 17 00:00:00 2001 From: aleiby Date: Sun, 25 Jan 2026 18:08:57 -0800 Subject: [PATCH] fix(startup): unify agent startup with beacon + instructions in CLI prompt (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All agents now receive their startup beacon + role-specific instructions via the CLI prompt, making sessions identifiable in /resume picker while removing unreliable post-startup nudges. Changes: - Rename FormatStartupNudge → FormatStartupBeacon, StartupNudgeConfig → BeaconConfig - Remove StartupNudge() function (no longer needed) - Remove PropulsionNudge() and PropulsionNudgeForRole() functions - Update deacon, witness, refinery, polecat managers to include beacon in CLI prompt - Update boot to inline beacon (can't import session due to import cycle) - Update daemon/lifecycle.go to include beacon via BuildCommandWithPrompt - Update cmd/deacon.go to include beacon in startup command - Remove redundant StartupNudge and PropulsionNudge calls from all startup paths The beacon is now part of the CLI prompt which is queued before Claude starts, making it more reliable than post-startup nudges which had timing issues. SessionStart hook runs gt prime automatically, so PropulsionNudge was redundant. Co-authored-by: Claude Opus 4.5 --- internal/boot/boot.go | 28 ++++++------- internal/cmd/boot.go | 5 ++- internal/cmd/crew_at.go | 10 ++--- internal/cmd/deacon.go | 26 +++--------- internal/cmd/handoff.go | 4 +- internal/cmd/mayor.go | 2 +- internal/cmd/start.go | 2 +- internal/crew/manager.go | 2 +- internal/daemon/lifecycle.go | 35 ++++++++-------- internal/deacon/manager.go | 22 +++------- internal/doctor/boot_check.go | 5 ++- internal/mayor/manager.go | 2 +- internal/polecat/session_manager.go | 26 +++++------- internal/refinery/manager.go | 25 ++++-------- internal/session/names.go | 62 ++++------------------------ internal/session/names_test.go | 63 ----------------------------- internal/session/startup.go | 44 +++++++++++--------- internal/session/startup_test.go | 60 +++++++++++++++++++++------ internal/session/town.go | 3 +- internal/witness/manager.go | 22 +++------- 20 files changed, 163 insertions(+), 285 deletions(-) diff --git a/internal/boot/boot.go b/internal/boot/boot.go index ad01d3c9..ed2230c9 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -12,16 +12,10 @@ import ( "time" "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" ) -// SessionName is the tmux session name for Boot. -// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix -// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot" -// would match when checking for "hq-deacon", causing HasSession("hq-deacon") -// to return true when only Boot is running. -const SessionName = "gt-boot" - // MarkerFileName is the lock file for Boot startup coordination. const MarkerFileName = ".boot-running" @@ -81,7 +75,7 @@ func (b *Boot) IsRunning() bool { // IsSessionAlive checks if the Boot tmux session exists. func (b *Boot) IsSessionAlive() bool { - has, err := b.tmux.HasSession(SessionName) + has, err := b.tmux.HasSession(session.BootSessionName()) return err == nil && has } @@ -163,7 +157,7 @@ func (b *Boot) spawnTmux(agentOverride string) error { // Kill any stale session first. // Use KillSessionWithProcesses to ensure all descendant processes are killed. if b.IsSessionAlive() { - _ = b.tmux.KillSessionWithProcesses(SessionName) + _ = b.tmux.KillSessionWithProcesses(session.BootSessionName()) } // Ensure boot directory exists (it should have CLAUDE.md with Boot context) @@ -171,22 +165,26 @@ func (b *Boot) spawnTmux(agentOverride string) error { return fmt.Errorf("ensuring boot dir: %w", err) } - // Build startup command with optional agent override - // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) + initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: "boot", + Sender: "daemon", + Topic: "triage", + }, "Run `gt boot triage` now.") + var startCmd string if agentOverride != "" { var err error - startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", "gt boot triage", agentOverride) + startCmd, err = config.BuildAgentStartupCommandWithAgentOverride("boot", "", b.townRoot, "", initialPrompt, agentOverride) if err != nil { return fmt.Errorf("building startup command with agent override: %w", err) } } else { - startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", "gt boot triage") + startCmd = config.BuildAgentStartupCommand("boot", "", b.townRoot, "", initialPrompt) } // Create session with command directly to avoid send-keys race condition. // See: https://github.com/anthropics/gastown/issues/280 - if err := b.tmux.NewSessionWithCommand(SessionName, b.bootDir, startCmd); err != nil { + if err := b.tmux.NewSessionWithCommand(session.BootSessionName(), b.bootDir, startCmd); err != nil { return fmt.Errorf("creating boot session: %w", err) } @@ -196,7 +194,7 @@ func (b *Boot) spawnTmux(agentOverride string) error { TownRoot: b.townRoot, }) for k, v := range envVars { - _ = b.tmux.SetEnvironment(SessionName, k, v) + _ = b.tmux.SetEnvironment(session.BootSessionName(), k, v) } return nil diff --git a/internal/cmd/boot.go b/internal/cmd/boot.go index 0af2cf7f..afd155fb 100644 --- a/internal/cmd/boot.go +++ b/internal/cmd/boot.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/boot" "github.com/steveyegge/gastown/internal/deacon" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) @@ -141,7 +142,7 @@ func runBootStatus(cmd *cobra.Command, args []string) error { } if sessionAlive { - fmt.Printf(" Session: %s (alive)\n", boot.SessionName) + fmt.Printf(" Session: %s (alive)\n", session.BootSessionName()) } else { fmt.Printf(" Session: %s\n", style.Dim.Render("not running")) } @@ -219,7 +220,7 @@ func runBootSpawn(cmd *cobra.Command, args []string) error { if b.IsDegraded() { fmt.Println("Boot spawned in degraded mode (subprocess)") } else { - fmt.Printf("Boot spawned in session: %s\n", boot.SessionName) + fmt.Printf("Boot spawned in session: %s\n", session.BootSessionName()) } return nil diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index d8a6918d..8c01b209 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -193,10 +193,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error { } // Build startup beacon for predecessor discovery via /resume - // Use FormatStartupNudge instead of bare "gt prime" which confuses agents + // Use FormatStartupBeacon 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{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: address, Sender: "human", Topic: "start", @@ -242,9 +242,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error { } // Build startup beacon for predecessor discovery via /resume - // Use FormatStartupNudge instead of bare "gt prime" which confuses agents + // Use FormatStartupBeacon instead of bare "gt prime" which confuses agents address := fmt.Sprintf("%s/crew/%s", r.Name, name) - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: address, Sender: "human", Topic: "restart", @@ -301,7 +301,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error { // 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{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: address, Sender: "human", Topic: "start", diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 17286bcc..981e3348 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -413,9 +413,12 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error { return fmt.Errorf("creating deacon settings: %w", err) } - // Build startup command first - // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", "", agentOverride) + initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: "deacon", + Sender: "daemon", + Topic: "patrol", + }, "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it.") + startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", initialPrompt, agentOverride) if err != nil { return fmt.Errorf("building startup command: %w", err) } @@ -451,23 +454,6 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error { runtimeConfig := config.LoadRuntimeConfig("") _ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig) - // Inject startup nudge for predecessor discovery via /resume - if err := session.StartupNudge(t, sessionName, session.StartupNudgeConfig{ - Recipient: "deacon", - Sender: "daemon", - Topic: "patrol", - }); err != nil { - style.PrintWarning("failed to send startup nudge: %v", err) - } - - // GUPP: Gas Town Universal Propulsion Principle - // Send the propulsion nudge to trigger autonomous patrol execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - if err := t.NudgeSession(sessionName, session.PropulsionNudgeForRole("deacon", deaconDir)); err != nil { - return fmt.Errorf("sending propulsion nudge: %w", err) - } - return nil } diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index e1de456c..19a26d20 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -391,9 +391,9 @@ func buildRestartCommand(sessionName string) (string, error) { gtRole := identity.GTRole() // Build startup beacon for predecessor discovery via /resume - // Use FormatStartupNudge instead of bare "gt prime" which confuses agents + // Use FormatStartupBeacon instead of bare "gt prime" which confuses agents // The SessionStart hook handles context injection (gt prime --hook) - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: identity.Address(), Sender: "self", Topic: "handoff", diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 08c5578a..c4c561fd 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -188,7 +188,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error { } // Build startup beacon for context (like gt handoff does) - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: "mayor", Sender: "human", Topic: "attach", diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 12efb2d8..2061b041 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -404,7 +404,7 @@ func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot strin // Agent has exited, restart it // Build startup beacon for predecessor discovery via /resume address := fmt.Sprintf("%s/crew/%s", r.Name, crewName) - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: address, Sender: "human", Topic: "restart", diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 51a3c4a7..e30a4931 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -491,7 +491,7 @@ func (m *Manager) Start(name string, opts StartOptions) error { if topic == "" { topic = "start" } - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: address, Sender: "human", Topic: topic, diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 9a26c86b..650e53b0 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -395,20 +395,6 @@ func (d *Daemon) restartSession(sessionName, identity string) error { _ = d.tmux.AcceptBypassPermissionsWarning(sessionName) time.Sleep(constants.ShutdownNotifyDelay) - // GUPP: Gas Town Universal Propulsion Principle - // Send startup nudge for predecessor discovery via /resume - recipient := identityToBDActor(identity) - _ = session.StartupNudge(d.tmux, sessionName, session.StartupNudgeConfig{ - Recipient: recipient, - Sender: "deacon", - Topic: "lifecycle-restart", - }) // Non-fatal - - // Send propulsion nudge to trigger autonomous execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - _ = d.tmux.NudgeSession(sessionName, session.PropulsionNudgeForRole(parsed.RoleType, workDir)) // Non-fatal - return nil } @@ -464,6 +450,7 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit // getStartCommand determines the startup command for an agent. // Uses role config if available, then role-based agent selection, then hardcoded defaults. +// Includes beacon + role-specific instructions in the CLI prompt. func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string { // If role config is available, use it if roleConfig != nil && roleConfig.StartCommand != "" { @@ -479,8 +466,22 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde // Use role-based agent resolution for per-role model selection runtimeConfig := config.ResolveRoleAgentConfig(parsed.RoleType, d.config.TownRoot, rigPath) + // Build recipient for beacon + recipient := identityToBDActor(parsed.RigName + "/" + parsed.RoleType) + if parsed.AgentName != "" { + recipient = identityToBDActor(parsed.RigName + "/" + parsed.RoleType + "/" + parsed.AgentName) + } + if parsed.RoleType == "deacon" || parsed.RoleType == "mayor" { + recipient = parsed.RoleType + } + prompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: recipient, + Sender: "daemon", + Topic: "lifecycle-restart", + }, "Check your hook and begin work.") + // Build default command using the role-resolved runtime config - defaultCmd := "exec " + runtimeConfig.BuildCommand() + defaultCmd := "exec " + runtimeConfig.BuildCommandWithPrompt(prompt) if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" { defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": runtimeConfig.Session.SessionIDEnv}) } @@ -498,7 +499,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde TownRoot: d.config.TownRoot, SessionIDEnv: sessionIDEnv, }) - return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars) + return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars) } if parsed.RoleType == "crew" { @@ -513,7 +514,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde TownRoot: d.config.TownRoot, SessionIDEnv: sessionIDEnv, }) - return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars) + return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars) } return defaultCmd diff --git a/internal/deacon/manager.go b/internal/deacon/manager.go index 8d9164c5..a3746c94 100644 --- a/internal/deacon/manager.go +++ b/internal/deacon/manager.go @@ -80,10 +80,11 @@ func (m *Manager) Start(agentOverride string) error { return fmt.Errorf("ensuring Claude settings: %w", err) } - // Build startup command with initial prompt for autonomous patrol. - // The prompt triggers GUPP: deacon starts patrol immediately without waiting for input. - // This prevents the agent from sitting idle at the prompt after SessionStart hooks run. - initialPrompt := "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it." + initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: "deacon", + Sender: "daemon", + Topic: "patrol", + }, "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it.") startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", m.townRoot, "", initialPrompt, agentOverride) if err != nil { return fmt.Errorf("building startup command: %w", err) @@ -121,19 +122,6 @@ func (m *Manager) Start(agentOverride string) error { time.Sleep(constants.ShutdownNotifyDelay) - // Inject startup nudge for predecessor discovery via /resume - _ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{ - Recipient: "deacon", - Sender: "daemon", - Topic: "patrol", - }) // Non-fatal - - // GUPP: Gas Town Universal Propulsion Principle - // Send the propulsion nudge to trigger autonomous patrol execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - _ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal - return nil } diff --git a/internal/doctor/boot_check.go b/internal/doctor/boot_check.go index cc2ed565..25ec31bd 100644 --- a/internal/doctor/boot_check.go +++ b/internal/doctor/boot_check.go @@ -6,6 +6,7 @@ import ( "time" "github.com/steveyegge/gastown/internal/boot" + "github.com/steveyegge/gastown/internal/session" ) // BootHealthCheck verifies Boot watchdog health. @@ -63,9 +64,9 @@ func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult { // Check 2: Session alive sessionAlive := b.IsSessionAlive() if sessionAlive { - details = append(details, fmt.Sprintf("Session: %s (alive)", boot.SessionName)) + details = append(details, fmt.Sprintf("Session: %s (alive)", session.BootSessionName())) } else { - details = append(details, fmt.Sprintf("Session: %s (not running)", boot.SessionName)) + details = append(details, fmt.Sprintf("Session: %s (not running)", session.BootSessionName())) } // Check 3: Last execution status diff --git a/internal/mayor/manager.go b/internal/mayor/manager.go index 855cae31..ebe3ec44 100644 --- a/internal/mayor/manager.go +++ b/internal/mayor/manager.go @@ -80,7 +80,7 @@ func (m *Manager) Start(agentOverride string) error { // Build startup beacon with explicit instructions (matches gt handoff behavior) // This ensures the agent has clear context immediately, not after nudges arrive - beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ + beacon := session.FormatStartupBeacon(session.BeaconConfig{ Recipient: "mayor", Sender: "human", Topic: "cold-start", diff --git a/internal/polecat/session_manager.go b/internal/polecat/session_manager.go index e96f49cf..6d3ad923 100644 --- a/internal/polecat/session_manager.go +++ b/internal/polecat/session_manager.go @@ -180,10 +180,19 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error { return fmt.Errorf("ensuring runtime settings: %w", err) } - // Build startup command first + // Build startup command with beacon for predecessor discovery. + // Topic "assigned" already includes instructions in FormatStartupBeacon. + address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat) + beacon := session.FormatStartupBeacon(session.BeaconConfig{ + Recipient: address, + Sender: "witness", + Topic: "assigned", + MolID: opts.Issue, + }) + command := opts.Command if command == "" { - command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "") + command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, beacon) } // Prepend runtime config dir env if needed if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" { @@ -237,19 +246,6 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error { runtime.SleepForReadyDelay(runtimeConfig) _ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", runtimeConfig) - // Inject startup nudge for predecessor discovery via /resume - address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat) - debugSession("StartupNudge", session.StartupNudge(m.tmux, sessionID, session.StartupNudgeConfig{ - Recipient: address, - Sender: "witness", - Topic: "assigned", - MolID: opts.Issue, - })) - - // GUPP: Send propulsion nudge to trigger autonomous work execution - time.Sleep(2 * time.Second) - debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, session.PropulsionNudge())) - // Verify session survived startup - if the command crashed, the session may have died. // Without this check, Start() would return success even if the pane died during initialization. running, err = m.tmux.HasSession(sessionID) diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 4159e0c5..fcc5a085 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -134,16 +134,21 @@ func (m *Manager) Start(foreground bool, agentOverride string) error { return fmt.Errorf("ensuring runtime settings: %w", err) } - // Build startup command first + initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: fmt.Sprintf("%s/refinery", m.rig.Name), + Sender: "deacon", + Topic: "patrol", + }, "Check your hook and begin patrol.") + var command string if agentOverride != "" { var err error - command, err = config.BuildAgentStartupCommandWithAgentOverride("refinery", m.rig.Name, townRoot, m.rig.Path, "", agentOverride) + command, err = config.BuildAgentStartupCommandWithAgentOverride("refinery", m.rig.Name, townRoot, m.rig.Path, initialPrompt, agentOverride) if err != nil { return fmt.Errorf("building startup command with agent override: %w", err) } } else { - command = config.BuildAgentStartupCommand("refinery", m.rig.Name, townRoot, m.rig.Path, "") + command = config.BuildAgentStartupCommand("refinery", m.rig.Name, townRoot, m.rig.Path, initialPrompt) } // Create session with command directly to avoid send-keys race condition. @@ -189,20 +194,6 @@ func (m *Manager) Start(foreground bool, agentOverride string) error { runtime.SleepForReadyDelay(runtimeConfig) _ = runtime.RunStartupFallback(t, sessionID, "refinery", runtimeConfig) - // Inject startup nudge for predecessor discovery via /resume - address := fmt.Sprintf("%s/refinery", m.rig.Name) - _ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{ - Recipient: address, - Sender: "deacon", - Topic: "patrol", - }) // Non-fatal - - // GUPP: Gas Town Universal Propulsion Principle - // Send the propulsion nudge to trigger autonomous patrol execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - _ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal - return nil } diff --git a/internal/session/names.go b/internal/session/names.go index a36a077c..73205b4e 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -3,9 +3,6 @@ package session import ( "fmt" - "os" - "path/filepath" - "strings" ) // Prefix is the common prefix for rig-level Gas Town tmux sessions. @@ -46,57 +43,12 @@ func PolecatSessionName(rig, name string) string { return fmt.Sprintf("%s%s-%s", Prefix, rig, name) } -// PropulsionNudge generates the GUPP (Gas Town Universal Propulsion Principle) nudge. -// This is sent after the beacon to trigger autonomous work execution. -// The agent receives this as user input, triggering the propulsion principle: -// "If work is on your hook, YOU RUN IT." -func PropulsionNudge() string { - return "Run `gt hook` to check your hook and begin work." +// BootSessionName returns the session name for the Boot watchdog. +// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix +// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot" +// would match when checking for "hq-deacon", causing HasSession("hq-deacon") +// to return true when only Boot is running. +func BootSessionName() string { + return Prefix + "boot" } -// PropulsionNudgeForRole generates a role-specific GUPP nudge. -// Different roles have different startup flows: -// - polecat/crew: Check hook for slung work -// - witness/refinery: Start patrol cycle -// - deacon: Start heartbeat patrol -// - mayor: Check mail for coordination work -// -// The workDir parameter is used to locate .runtime/session_id for including -// session ID in the message (for Claude Code /resume picker discovery). -func PropulsionNudgeForRole(role, workDir string) string { - var msg string - switch role { - case "polecat", "crew": - msg = PropulsionNudge() - case "witness": - msg = "Run `gt prime` to check patrol status and begin work." - case "refinery": - msg = "Run `gt prime` to check MQ status and begin patrol." - case "deacon": - msg = "Run `gt prime` to check patrol status and begin heartbeat cycle." - case "mayor": - msg = "Run `gt prime` to check mail and begin coordination." - default: - msg = PropulsionNudge() - } - - // Append session ID if available (for /resume picker visibility) - if sessionID := readSessionID(workDir); sessionID != "" { - msg = fmt.Sprintf("%s [session:%s]", msg, sessionID) - } - return msg -} - -// readSessionID reads the session ID from .runtime/session_id if it exists. -// Returns empty string if the file doesn't exist or can't be read. -func readSessionID(workDir string) string { - if workDir == "" { - return "" - } - sessionPath := filepath.Join(workDir, ".runtime", "session_id") - data, err := os.ReadFile(sessionPath) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} diff --git a/internal/session/names_test.go b/internal/session/names_test.go index ad842328..e704a777 100644 --- a/internal/session/names_test.go +++ b/internal/session/names_test.go @@ -1,9 +1,6 @@ package session import ( - "os" - "path/filepath" - "strings" "testing" ) @@ -109,63 +106,3 @@ func TestPrefix(t *testing.T) { t.Errorf("Prefix = %q, want %q", Prefix, want) } } - -func TestPropulsionNudgeForRole_WithSessionID(t *testing.T) { - // Create temp directory with session_id file - tmpDir := t.TempDir() - runtimeDir := filepath.Join(tmpDir, ".runtime") - if err := os.MkdirAll(runtimeDir, 0755); err != nil { - t.Fatalf("creating runtime dir: %v", err) - } - - sessionID := "test-session-abc123" - if err := os.WriteFile(filepath.Join(runtimeDir, "session_id"), []byte(sessionID), 0644); err != nil { - t.Fatalf("writing session_id: %v", err) - } - - // Test that session ID is appended - msg := PropulsionNudgeForRole("mayor", tmpDir) - if !strings.Contains(msg, "[session:test-session-abc123]") { - t.Errorf("PropulsionNudgeForRole(mayor, tmpDir) = %q, should contain [session:test-session-abc123]", msg) - } -} - -func TestPropulsionNudgeForRole_WithoutSessionID(t *testing.T) { - // Use nonexistent directory - msg := PropulsionNudgeForRole("mayor", "/nonexistent-dir-12345") - if strings.Contains(msg, "[session:") { - t.Errorf("PropulsionNudgeForRole(mayor, /nonexistent) = %q, should NOT contain session ID", msg) - } -} - -func TestPropulsionNudgeForRole_EmptyWorkDir(t *testing.T) { - // Empty workDir should not crash and should not include session ID - msg := PropulsionNudgeForRole("mayor", "") - if strings.Contains(msg, "[session:") { - t.Errorf("PropulsionNudgeForRole(mayor, \"\") = %q, should NOT contain session ID", msg) - } -} - -func TestPropulsionNudgeForRole_AllRoles(t *testing.T) { - tests := []struct { - role string - contains string - }{ - {"polecat", "gt hook"}, - {"crew", "gt hook"}, - {"witness", "gt prime"}, - {"refinery", "gt prime"}, - {"deacon", "gt prime"}, - {"mayor", "gt prime"}, - {"unknown", "gt hook"}, - } - - for _, tt := range tests { - t.Run(tt.role, func(t *testing.T) { - msg := PropulsionNudgeForRole(tt.role, "") - if !strings.Contains(msg, tt.contains) { - t.Errorf("PropulsionNudgeForRole(%q, \"\") = %q, should contain %q", tt.role, msg, tt.contains) - } - }) - } -} diff --git a/internal/session/startup.go b/internal/session/startup.go index be89c438..024878b7 100644 --- a/internal/session/startup.go +++ b/internal/session/startup.go @@ -4,12 +4,11 @@ package session import ( "fmt" "time" - - "github.com/steveyegge/gastown/internal/tmux" ) -// StartupNudgeConfig configures a startup nudge message. -type StartupNudgeConfig struct { +// BeaconConfig configures a startup beacon message. +// The beacon is injected into the CLI prompt to identify sessions in /resume picker. +type BeaconConfig struct { // Recipient is the address of the agent being nudged. // Examples: "gastown/crew/gus", "deacon", "gastown/witness" Recipient string @@ -27,27 +26,17 @@ type StartupNudgeConfig struct { MolID string } -// StartupNudge sends a formatted startup message to a Claude Code session. -// The message becomes the session title in Claude Code's /resume picker, -// enabling workers to find predecessor sessions. +// FormatStartupBeacon builds the formatted startup beacon message. +// The beacon is injected into the CLI prompt, making sessions identifiable +// in Claude Code's /resume picker for predecessor discovery. // // Format: [GAS TOWN] <- // // Examples: // - [GAS TOWN] gastown/crew/gus <- deacon • 2025-12-30T15:42 • assigned:gt-abc12 -// - [GAS TOWN] deacon <- mayor • 2025-12-30T08:00 • cold-start -// - [GAS TOWN] gastown/witness <- self • 2025-12-30T14:00 • handoff -// -// The message content doesn't trigger GUPP - CLAUDE.md and hooks handle that. -// The metadata makes sessions identifiable in /resume. -func StartupNudge(t *tmux.Tmux, session string, cfg StartupNudgeConfig) error { - message := FormatStartupNudge(cfg) - return t.NudgeSession(session, message) -} - -// FormatStartupNudge builds the formatted startup nudge message. -// Separated from StartupNudge for testing and reuse. -func FormatStartupNudge(cfg StartupNudgeConfig) string { +// - [GAS TOWN] deacon <- daemon • 2025-12-30T08:00 • patrol +// - [GAS TOWN] gastown/witness <- deacon • 2025-12-30T14:00 • patrol +func FormatStartupBeacon(cfg BeaconConfig) string { // Use local time in compact format timestamp := time.Now().Format("2006-01-02T15:04") @@ -91,3 +80,18 @@ func FormatStartupNudge(cfg StartupNudgeConfig) string { return beacon } + +// BuildStartupPrompt creates the CLI prompt for agent startup. +// +// GUPP (Gas Town Universal Propulsion Principle) implementation: +// - Beacon identifies session for /resume predecessor discovery +// - Instructions tell agent to start working immediately +// - SessionStart hook runs `gt prime` which injects full context including +// "AUTONOMOUS WORK MODE" instructions when work is hooked +// +// This replaces the old two-step StartupNudge + PropulsionNudge pattern. +// The beacon is processed in Claude's first turn along with gt prime context, +// so no separate propulsion nudge is needed. +func BuildStartupPrompt(cfg BeaconConfig, instructions string) string { + return FormatStartupBeacon(cfg) + "\n\n" + instructions +} diff --git a/internal/session/startup_test.go b/internal/session/startup_test.go index 4d8ac373..1212fe2f 100644 --- a/internal/session/startup_test.go +++ b/internal/session/startup_test.go @@ -5,16 +5,16 @@ import ( "testing" ) -func TestFormatStartupNudge(t *testing.T) { +func TestFormatStartupBeacon(t *testing.T) { tests := []struct { name string - cfg StartupNudgeConfig + cfg BeaconConfig wantSub []string // substrings that must appear wantNot []string // substrings that must NOT appear }{ { name: "assigned with mol-id", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "gastown/crew/gus", Sender: "deacon", Topic: "assigned", @@ -31,7 +31,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "cold-start no mol-id", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "deacon", Sender: "mayor", Topic: "cold-start", @@ -49,7 +49,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "handoff self", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "gastown/witness", Sender: "self", Topic: "handoff", @@ -66,7 +66,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "mol-id only", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "gastown/polecats/Toast", Sender: "witness", MolID: "gt-xyz99", @@ -80,7 +80,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "empty topic defaults to ready", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "deacon", Sender: "mayor", }, @@ -91,7 +91,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "start includes fallback instructions", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "beads/crew/fang", Sender: "human", Topic: "start", @@ -106,7 +106,7 @@ func TestFormatStartupNudge(t *testing.T) { }, { name: "restart includes fallback instructions", - cfg: StartupNudgeConfig{ + cfg: BeaconConfig{ Recipient: "gastown/crew/george", Sender: "human", Topic: "restart", @@ -122,19 +122,55 @@ func TestFormatStartupNudge(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := FormatStartupNudge(tt.cfg) + got := FormatStartupBeacon(tt.cfg) for _, sub := range tt.wantSub { if !strings.Contains(got, sub) { - t.Errorf("FormatStartupNudge() = %q, want to contain %q", got, sub) + t.Errorf("FormatStartupBeacon() = %q, want to contain %q", got, sub) } } for _, sub := range tt.wantNot { if strings.Contains(got, sub) { - t.Errorf("FormatStartupNudge() = %q, should NOT contain %q", got, sub) + t.Errorf("FormatStartupBeacon() = %q, should NOT contain %q", got, sub) } } }) } } + +func TestBuildStartupPrompt(t *testing.T) { + // BuildStartupPrompt combines beacon + instructions + cfg := BeaconConfig{ + Recipient: "deacon", + Sender: "daemon", + Topic: "patrol", + } + instructions := "Start patrol immediately." + + got := BuildStartupPrompt(cfg, instructions) + + // Should contain beacon parts + if !strings.Contains(got, "[GAS TOWN]") { + t.Errorf("BuildStartupPrompt() missing beacon header") + } + if !strings.Contains(got, "deacon") { + t.Errorf("BuildStartupPrompt() missing recipient") + } + if !strings.Contains(got, "<- daemon") { + t.Errorf("BuildStartupPrompt() missing sender") + } + if !strings.Contains(got, "patrol") { + t.Errorf("BuildStartupPrompt() missing topic") + } + + // Should contain instructions after beacon + if !strings.Contains(got, instructions) { + t.Errorf("BuildStartupPrompt() missing instructions") + } + + // Should have blank line between beacon and instructions + if !strings.Contains(got, "\n\n"+instructions) { + t.Errorf("BuildStartupPrompt() missing blank line before instructions") + } +} diff --git a/internal/session/town.go b/internal/session/town.go index f361867a..36abd51f 100644 --- a/internal/session/town.go +++ b/internal/session/town.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/steveyegge/gastown/internal/boot" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/tmux" ) @@ -22,7 +21,7 @@ type TownSession struct { func TownSessions() []TownSession { return []TownSession{ {"Mayor", MayorSessionName()}, - {"Boot", boot.SessionName}, + {"Boot", BootSessionName()}, {"Deacon", DeaconSessionName()}, } } diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 48d6ec8d..dbd7f2aa 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -182,20 +182,6 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st time.Sleep(constants.ShutdownNotifyDelay) - // Inject startup nudge for predecessor discovery via /resume - address := fmt.Sprintf("%s/witness", m.rig.Name) - _ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{ - Recipient: address, - Sender: "deacon", - Topic: "patrol", - }) // Non-fatal - - // GUPP: Gas Town Universal Propulsion Principle - // Send the propulsion nudge to trigger autonomous patrol execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - _ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal - return nil } @@ -236,9 +222,11 @@ func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string, if roleConfig != nil && roleConfig.StartCommand != "" { return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil } - // Add initial prompt for autonomous patrol startup. - // The prompt triggers GUPP: witness starts patrol immediately without waiting for input. - initialPrompt := "I am Witness for " + rigName + ". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it." + initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{ + Recipient: fmt.Sprintf("%s/witness", rigName), + Sender: "deacon", + Topic: "patrol", + }, "I am Witness for "+rigName+". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it.") command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", rigName, townRoot, rigPath, initialPrompt, agentOverride) if err != nil { return "", fmt.Errorf("building startup command: %w", err)