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)