fix(startup): unify agent startup with beacon + instructions in CLI prompt (#977)

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 <noreply@anthropic.com>
This commit is contained in:
aleiby
2026-01-25 18:08:57 -08:00
committed by GitHub
parent 92ccacffd9
commit 31bd120077
20 changed files with 163 additions and 285 deletions

View File

@@ -12,16 +12,10 @@ import (
"time" "time"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux" "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. // MarkerFileName is the lock file for Boot startup coordination.
const MarkerFileName = ".boot-running" const MarkerFileName = ".boot-running"
@@ -81,7 +75,7 @@ func (b *Boot) IsRunning() bool {
// IsSessionAlive checks if the Boot tmux session exists. // IsSessionAlive checks if the Boot tmux session exists.
func (b *Boot) IsSessionAlive() bool { func (b *Boot) IsSessionAlive() bool {
has, err := b.tmux.HasSession(SessionName) has, err := b.tmux.HasSession(session.BootSessionName())
return err == nil && has return err == nil && has
} }
@@ -163,7 +157,7 @@ func (b *Boot) spawnTmux(agentOverride string) error {
// Kill any stale session first. // Kill any stale session first.
// Use KillSessionWithProcesses to ensure all descendant processes are killed. // Use KillSessionWithProcesses to ensure all descendant processes are killed.
if b.IsSessionAlive() { if b.IsSessionAlive() {
_ = b.tmux.KillSessionWithProcesses(SessionName) _ = b.tmux.KillSessionWithProcesses(session.BootSessionName())
} }
// Ensure boot directory exists (it should have CLAUDE.md with Boot context) // 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) return fmt.Errorf("ensuring boot dir: %w", err)
} }
// Build startup command with optional agent override initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) Recipient: "boot",
Sender: "daemon",
Topic: "triage",
}, "Run `gt boot triage` now.")
var startCmd string var startCmd string
if agentOverride != "" { if agentOverride != "" {
var err error 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 { if err != nil {
return fmt.Errorf("building startup command with agent override: %w", err) return fmt.Errorf("building startup command with agent override: %w", err)
} }
} else { } 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. // Create session with command directly to avoid send-keys race condition.
// See: https://github.com/anthropics/gastown/issues/280 // 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) return fmt.Errorf("creating boot session: %w", err)
} }
@@ -196,7 +194,7 @@ func (b *Boot) spawnTmux(agentOverride string) error {
TownRoot: b.townRoot, TownRoot: b.townRoot,
}) })
for k, v := range envVars { for k, v := range envVars {
_ = b.tmux.SetEnvironment(SessionName, k, v) _ = b.tmux.SetEnvironment(session.BootSessionName(), k, v)
} }
return nil return nil

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/boot" "github.com/steveyegge/gastown/internal/boot"
"github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -141,7 +142,7 @@ func runBootStatus(cmd *cobra.Command, args []string) error {
} }
if sessionAlive { if sessionAlive {
fmt.Printf(" Session: %s (alive)\n", boot.SessionName) fmt.Printf(" Session: %s (alive)\n", session.BootSessionName())
} else { } else {
fmt.Printf(" Session: %s\n", style.Dim.Render("not running")) 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() { if b.IsDegraded() {
fmt.Println("Boot spawned in degraded mode (subprocess)") fmt.Println("Boot spawned in degraded mode (subprocess)")
} else { } else {
fmt.Printf("Boot spawned in session: %s\n", boot.SessionName) fmt.Printf("Boot spawned in session: %s\n", session.BootSessionName())
} }
return nil return nil

View File

@@ -193,10 +193,10 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
} }
// Build startup beacon for predecessor discovery via /resume // 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) // The SessionStart hook handles context injection (gt prime --hook)
address := fmt.Sprintf("%s/crew/%s", r.Name, name) address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: address, Recipient: address,
Sender: "human", Sender: "human",
Topic: "start", Topic: "start",
@@ -242,9 +242,9 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
} }
// Build startup beacon for predecessor discovery via /resume // 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) address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: address, Recipient: address,
Sender: "human", Sender: "human",
Topic: "restart", 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 // We're in the session at a shell prompt - start the agent
// Build startup beacon for predecessor discovery via /resume // Build startup beacon for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", r.Name, name) address := fmt.Sprintf("%s/crew/%s", r.Name, name)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: address, Recipient: address,
Sender: "human", Sender: "human",
Topic: "start", Topic: "start",

View File

@@ -413,9 +413,12 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
return fmt.Errorf("creating deacon settings: %w", err) return fmt.Errorf("creating deacon settings: %w", err)
} }
// Build startup command first initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes Recipient: "deacon",
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", townRoot, "", "", agentOverride) 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 { if err != nil {
return fmt.Errorf("building startup command: %w", err) return fmt.Errorf("building startup command: %w", err)
} }
@@ -451,23 +454,6 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
runtimeConfig := config.LoadRuntimeConfig("") runtimeConfig := config.LoadRuntimeConfig("")
_ = runtime.RunStartupFallback(t, sessionName, "deacon", runtimeConfig) _ = 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 return nil
} }

View File

@@ -391,9 +391,9 @@ func buildRestartCommand(sessionName string) (string, error) {
gtRole := identity.GTRole() gtRole := identity.GTRole()
// Build startup beacon for predecessor discovery via /resume // 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) // The SessionStart hook handles context injection (gt prime --hook)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: identity.Address(), Recipient: identity.Address(),
Sender: "self", Sender: "self",
Topic: "handoff", Topic: "handoff",

View File

@@ -188,7 +188,7 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
} }
// Build startup beacon for context (like gt handoff does) // Build startup beacon for context (like gt handoff does)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: "mayor", Recipient: "mayor",
Sender: "human", Sender: "human",
Topic: "attach", Topic: "attach",

View File

@@ -404,7 +404,7 @@ func startOrRestartCrewMember(t *tmux.Tmux, r *rig.Rig, crewName, townRoot strin
// Agent has exited, restart it // Agent has exited, restart it
// Build startup beacon for predecessor discovery via /resume // Build startup beacon for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", r.Name, crewName) address := fmt.Sprintf("%s/crew/%s", r.Name, crewName)
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: address, Recipient: address,
Sender: "human", Sender: "human",
Topic: "restart", Topic: "restart",

View File

@@ -491,7 +491,7 @@ func (m *Manager) Start(name string, opts StartOptions) error {
if topic == "" { if topic == "" {
topic = "start" topic = "start"
} }
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: address, Recipient: address,
Sender: "human", Sender: "human",
Topic: topic, Topic: topic,

View File

@@ -395,20 +395,6 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
_ = d.tmux.AcceptBypassPermissionsWarning(sessionName) _ = d.tmux.AcceptBypassPermissionsWarning(sessionName)
time.Sleep(constants.ShutdownNotifyDelay) 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 return nil
} }
@@ -464,6 +450,7 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit
// getStartCommand determines the startup command for an agent. // getStartCommand determines the startup command for an agent.
// Uses role config if available, then role-based agent selection, then hardcoded defaults. // 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 { func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string {
// If role config is available, use it // If role config is available, use it
if roleConfig != nil && roleConfig.StartCommand != "" { 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 // Use role-based agent resolution for per-role model selection
runtimeConfig := config.ResolveRoleAgentConfig(parsed.RoleType, d.config.TownRoot, rigPath) 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 // 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 != "" { if runtimeConfig.Session != nil && runtimeConfig.Session.SessionIDEnv != "" {
defaultCmd = config.PrependEnv(defaultCmd, map[string]string{"GT_SESSION_ID_ENV": 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, TownRoot: d.config.TownRoot,
SessionIDEnv: sessionIDEnv, SessionIDEnv: sessionIDEnv,
}) })
return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars) return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars)
} }
if parsed.RoleType == "crew" { if parsed.RoleType == "crew" {
@@ -513,7 +514,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
TownRoot: d.config.TownRoot, TownRoot: d.config.TownRoot,
SessionIDEnv: sessionIDEnv, SessionIDEnv: sessionIDEnv,
}) })
return config.PrependEnv("exec "+runtimeConfig.BuildCommand(), envVars) return config.PrependEnv("exec "+runtimeConfig.BuildCommandWithPrompt(prompt), envVars)
} }
return defaultCmd return defaultCmd

View File

@@ -80,10 +80,11 @@ func (m *Manager) Start(agentOverride string) error {
return fmt.Errorf("ensuring Claude settings: %w", err) return fmt.Errorf("ensuring Claude settings: %w", err)
} }
// Build startup command with initial prompt for autonomous patrol. initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
// The prompt triggers GUPP: deacon starts patrol immediately without waiting for input. Recipient: "deacon",
// This prevents the agent from sitting idle at the prompt after SessionStart hooks run. Sender: "daemon",
initialPrompt := "I am Deacon. Start patrol: check gt hook, if empty create mol-deacon-patrol wisp and execute it." 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) startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "", m.townRoot, "", initialPrompt, agentOverride)
if err != nil { if err != nil {
return fmt.Errorf("building startup command: %w", err) return fmt.Errorf("building startup command: %w", err)
@@ -121,19 +122,6 @@ func (m *Manager) Start(agentOverride string) error {
time.Sleep(constants.ShutdownNotifyDelay) 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 return nil
} }

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/steveyegge/gastown/internal/boot" "github.com/steveyegge/gastown/internal/boot"
"github.com/steveyegge/gastown/internal/session"
) )
// BootHealthCheck verifies Boot watchdog health. // BootHealthCheck verifies Boot watchdog health.
@@ -63,9 +64,9 @@ func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
// Check 2: Session alive // Check 2: Session alive
sessionAlive := b.IsSessionAlive() sessionAlive := b.IsSessionAlive()
if sessionAlive { if sessionAlive {
details = append(details, fmt.Sprintf("Session: %s (alive)", boot.SessionName)) details = append(details, fmt.Sprintf("Session: %s (alive)", session.BootSessionName()))
} else { } 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 // Check 3: Last execution status

View File

@@ -80,7 +80,7 @@ func (m *Manager) Start(agentOverride string) error {
// Build startup beacon with explicit instructions (matches gt handoff behavior) // Build startup beacon with explicit instructions (matches gt handoff behavior)
// This ensures the agent has clear context immediately, not after nudges arrive // This ensures the agent has clear context immediately, not after nudges arrive
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{ beacon := session.FormatStartupBeacon(session.BeaconConfig{
Recipient: "mayor", Recipient: "mayor",
Sender: "human", Sender: "human",
Topic: "cold-start", Topic: "cold-start",

View File

@@ -180,10 +180,19 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
return fmt.Errorf("ensuring runtime settings: %w", err) 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 command := opts.Command
if 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 // Prepend runtime config dir env if needed
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" { 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.SleepForReadyDelay(runtimeConfig)
_ = runtime.RunStartupFallback(m.tmux, sessionID, "polecat", 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. // 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. // Without this check, Start() would return success even if the pane died during initialization.
running, err = m.tmux.HasSession(sessionID) running, err = m.tmux.HasSession(sessionID)

View File

@@ -134,16 +134,21 @@ func (m *Manager) Start(foreground bool, agentOverride string) error {
return fmt.Errorf("ensuring runtime settings: %w", err) 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 var command string
if agentOverride != "" { if agentOverride != "" {
var err error 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 { if err != nil {
return fmt.Errorf("building startup command with agent override: %w", err) return fmt.Errorf("building startup command with agent override: %w", err)
} }
} else { } 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. // 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.SleepForReadyDelay(runtimeConfig)
_ = runtime.RunStartupFallback(t, sessionID, "refinery", 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 return nil
} }

View File

@@ -3,9 +3,6 @@ package session
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
// Prefix is the common prefix for rig-level Gas Town tmux sessions. // 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) return fmt.Sprintf("%s%s-%s", Prefix, rig, name)
} }
// PropulsionNudge generates the GUPP (Gas Town Universal Propulsion Principle) nudge. // BootSessionName returns the session name for the Boot watchdog.
// This is sent after the beacon to trigger autonomous work execution. // Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix
// The agent receives this as user input, triggering the propulsion principle: // matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot"
// "If work is on your hook, YOU RUN IT." // would match when checking for "hq-deacon", causing HasSession("hq-deacon")
func PropulsionNudge() string { // to return true when only Boot is running.
return "Run `gt hook` to check your hook and begin work." 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))
}

View File

@@ -1,9 +1,6 @@
package session package session
import ( import (
"os"
"path/filepath"
"strings"
"testing" "testing"
) )
@@ -109,63 +106,3 @@ func TestPrefix(t *testing.T) {
t.Errorf("Prefix = %q, want %q", Prefix, want) 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)
}
})
}
}

View File

@@ -4,12 +4,11 @@ package session
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/steveyegge/gastown/internal/tmux"
) )
// StartupNudgeConfig configures a startup nudge message. // BeaconConfig configures a startup beacon message.
type StartupNudgeConfig struct { // 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. // Recipient is the address of the agent being nudged.
// Examples: "gastown/crew/gus", "deacon", "gastown/witness" // Examples: "gastown/crew/gus", "deacon", "gastown/witness"
Recipient string Recipient string
@@ -27,27 +26,17 @@ type StartupNudgeConfig struct {
MolID string MolID string
} }
// StartupNudge sends a formatted startup message to a Claude Code session. // FormatStartupBeacon builds the formatted startup beacon message.
// The message becomes the session title in Claude Code's /resume picker, // The beacon is injected into the CLI prompt, making sessions identifiable
// enabling workers to find predecessor sessions. // in Claude Code's /resume picker for predecessor discovery.
// //
// Format: [GAS TOWN] <recipient> <- <sender> • <timestamp> • <topic[:mol-id]> // Format: [GAS TOWN] <recipient> <- <sender> • <timestamp> • <topic[:mol-id]>
// //
// Examples: // Examples:
// - [GAS TOWN] gastown/crew/gus <- deacon • 2025-12-30T15:42 • assigned:gt-abc12 // - [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] deacon <- daemon • 2025-12-30T08:00 • patrol
// - [GAS TOWN] gastown/witness <- self • 2025-12-30T14:00 • handoff // - [GAS TOWN] gastown/witness <- deacon • 2025-12-30T14:00 • patrol
// func FormatStartupBeacon(cfg BeaconConfig) string {
// 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 {
// Use local time in compact format // Use local time in compact format
timestamp := time.Now().Format("2006-01-02T15:04") timestamp := time.Now().Format("2006-01-02T15:04")
@@ -91,3 +80,18 @@ func FormatStartupNudge(cfg StartupNudgeConfig) string {
return beacon 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
}

View File

@@ -5,16 +5,16 @@ import (
"testing" "testing"
) )
func TestFormatStartupNudge(t *testing.T) { func TestFormatStartupBeacon(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cfg StartupNudgeConfig cfg BeaconConfig
wantSub []string // substrings that must appear wantSub []string // substrings that must appear
wantNot []string // substrings that must NOT appear wantNot []string // substrings that must NOT appear
}{ }{
{ {
name: "assigned with mol-id", name: "assigned with mol-id",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "gastown/crew/gus", Recipient: "gastown/crew/gus",
Sender: "deacon", Sender: "deacon",
Topic: "assigned", Topic: "assigned",
@@ -31,7 +31,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "cold-start no mol-id", name: "cold-start no mol-id",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "deacon", Recipient: "deacon",
Sender: "mayor", Sender: "mayor",
Topic: "cold-start", Topic: "cold-start",
@@ -49,7 +49,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "handoff self", name: "handoff self",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "gastown/witness", Recipient: "gastown/witness",
Sender: "self", Sender: "self",
Topic: "handoff", Topic: "handoff",
@@ -66,7 +66,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "mol-id only", name: "mol-id only",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "gastown/polecats/Toast", Recipient: "gastown/polecats/Toast",
Sender: "witness", Sender: "witness",
MolID: "gt-xyz99", MolID: "gt-xyz99",
@@ -80,7 +80,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "empty topic defaults to ready", name: "empty topic defaults to ready",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "deacon", Recipient: "deacon",
Sender: "mayor", Sender: "mayor",
}, },
@@ -91,7 +91,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "start includes fallback instructions", name: "start includes fallback instructions",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "beads/crew/fang", Recipient: "beads/crew/fang",
Sender: "human", Sender: "human",
Topic: "start", Topic: "start",
@@ -106,7 +106,7 @@ func TestFormatStartupNudge(t *testing.T) {
}, },
{ {
name: "restart includes fallback instructions", name: "restart includes fallback instructions",
cfg: StartupNudgeConfig{ cfg: BeaconConfig{
Recipient: "gastown/crew/george", Recipient: "gastown/crew/george",
Sender: "human", Sender: "human",
Topic: "restart", Topic: "restart",
@@ -122,19 +122,55 @@ func TestFormatStartupNudge(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := FormatStartupNudge(tt.cfg) got := FormatStartupBeacon(tt.cfg)
for _, sub := range tt.wantSub { for _, sub := range tt.wantSub {
if !strings.Contains(got, sub) { 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 { for _, sub := range tt.wantNot {
if strings.Contains(got, sub) { 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")
}
}

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/steveyegge/gastown/internal/boot"
"github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
@@ -22,7 +21,7 @@ type TownSession struct {
func TownSessions() []TownSession { func TownSessions() []TownSession {
return []TownSession{ return []TownSession{
{"Mayor", MayorSessionName()}, {"Mayor", MayorSessionName()},
{"Boot", boot.SessionName}, {"Boot", BootSessionName()},
{"Deacon", DeaconSessionName()}, {"Deacon", DeaconSessionName()},
} }
} }

View File

@@ -182,20 +182,6 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st
time.Sleep(constants.ShutdownNotifyDelay) 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 return nil
} }
@@ -236,9 +222,11 @@ func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string,
if roleConfig != nil && roleConfig.StartCommand != "" { if roleConfig != nil && roleConfig.StartCommand != "" {
return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil
} }
// Add initial prompt for autonomous patrol startup. initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
// The prompt triggers GUPP: witness starts patrol immediately without waiting for input. Recipient: fmt.Sprintf("%s/witness", rigName),
initialPrompt := "I am Witness for " + rigName + ". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it." 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) command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", rigName, townRoot, rigPath, initialPrompt, agentOverride)
if err != nil { if err != nil {
return "", fmt.Errorf("building startup command: %w", err) return "", fmt.Errorf("building startup command: %w", err)