From cf53e2852e5e9e1aafdb92194794df949c4fb0c1 Mon Sep 17 00:00:00 2001 From: nux Date: Fri, 2 Jan 2026 20:51:18 -0800 Subject: [PATCH] feat(session): Include session ID in PropulsionNudge for /resume picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropulsionNudgeForRole now accepts a workDir parameter and reads session ID from .runtime/session_id to append [session:xxx] to the nudge message. This enables Claude Code's /resume picker to discover Gas Town sessions. (gt-u49zh) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/deacon.go | 2 +- internal/cmd/mayor.go | 2 +- internal/cmd/start.go | 2 +- internal/cmd/witness.go | 2 +- internal/session/names.go | 45 +++++++++++++++++++---- internal/session/names_test.go | 67 +++++++++++++++++++++++++++++++++- 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index f64ccd19..f1ced7cf 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -355,7 +355,7 @@ func startDeaconSession(t *tmux.Tmux) error { // 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(DeaconSessionName, session.PropulsionNudgeForRole("deacon")) // Non-fatal + _ = t.NudgeSession(DeaconSessionName, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal return nil } diff --git a/internal/cmd/mayor.go b/internal/cmd/mayor.go index 04f6705e..07b62050 100644 --- a/internal/cmd/mayor.go +++ b/internal/cmd/mayor.go @@ -157,7 +157,7 @@ func startMayorSession(t *tmux.Tmux) error { // Send the propulsion nudge to trigger autonomous coordination. // Wait for beacon to be fully processed (needs to be separate prompt) time.Sleep(2 * time.Second) - _ = t.NudgeSession(MayorSessionName, session.PropulsionNudgeForRole("mayor")) // Non-fatal + _ = t.NudgeSession(MayorSessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal return nil } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index e7a8d894..d4b00552 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -363,7 +363,7 @@ func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) { // 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(sessionName, session.PropulsionNudgeForRole("refinery")) // Non-fatal + _ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal return true, nil } diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index 7b94ade1..ad382c74 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -367,7 +367,7 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { // 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(sessionName, session.PropulsionNudgeForRole("witness")) // Non-fatal + _ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal return true, nil } diff --git a/internal/session/names.go b/internal/session/names.go index a3ce5cf2..5703273f 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -1,7 +1,12 @@ // Package session provides polecat session lifecycle management. package session -import "fmt" +import ( + "fmt" + "os" + "path/filepath" + "strings" +) // Prefix is the common prefix for all Gas Town tmux session names. const Prefix = "gt-" @@ -50,19 +55,43 @@ func PropulsionNudge() string { // - witness/refinery: Start patrol cycle // - deacon: Start heartbeat patrol // - mayor: Check mail for coordination work -func PropulsionNudgeForRole(role string) string { +// +// 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": - return PropulsionNudge() + msg = PropulsionNudge() case "witness": - return "Run `gt prime` to check patrol status and begin work." + msg = "Run `gt prime` to check patrol status and begin work." case "refinery": - return "Run `gt prime` to check MQ status and begin patrol." + msg = "Run `gt prime` to check MQ status and begin patrol." case "deacon": - return "Run `gt prime` to check patrol status and begin heartbeat cycle." + msg = "Run `gt prime` to check patrol status and begin heartbeat cycle." case "mayor": - return "Run `gt prime` to check mail and begin coordination." + msg = "Run `gt prime` to check mail and begin coordination." default: - return PropulsionNudge() + 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 d8137406..55eb6132 100644 --- a/internal/session/names_test.go +++ b/internal/session/names_test.go @@ -1,6 +1,11 @@ package session -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" +) func TestMayorSessionName(t *testing.T) { want := "gt-mayor" @@ -102,3 +107,63 @@ 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) + } + }) + } +}