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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] <recipient> <- <sender> • <timestamp> • <topic[:mol-id]>
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user