diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 28506610..a63cee7a 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -371,34 +372,13 @@ func sessionWorkDir(sessionName, townRoot string) (string, error) { } // sessionToGTRole converts a session name to a GT_ROLE value. +// Uses session.ParseSessionName for consistent parsing across the codebase. func sessionToGTRole(sessionName string) string { - switch { - case sessionName == "gt-mayor": - return "mayor" - case sessionName == "gt-deacon": - return "deacon" - case strings.Contains(sessionName, "-crew-"): - // gt--crew- -> /crew/ - parts := strings.Split(sessionName, "-") - for i, p := range parts { - if p == "crew" && i > 1 && i < len(parts)-1 { - rig := strings.Join(parts[1:i], "-") - name := strings.Join(parts[i+1:], "-") - return fmt.Sprintf("%s/crew/%s", rig, name) - } - } - return "" - case strings.HasSuffix(sessionName, "-witness"): - rig := strings.TrimPrefix(sessionName, "gt-") - rig = strings.TrimSuffix(rig, "-witness") - return fmt.Sprintf("%s/witness", rig) - case strings.HasSuffix(sessionName, "-refinery"): - rig := strings.TrimPrefix(sessionName, "gt-") - rig = strings.TrimSuffix(rig, "-refinery") - return fmt.Sprintf("%s/refinery", rig) - default: + identity, err := session.ParseSessionName(sessionName) + if err != nil { return "" } + return identity.GTRole() } // detectTownRootFromCwd walks up from the current directory to find the town root. diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index a450904b..3721c270 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) @@ -362,32 +363,14 @@ func resolveTargetAgent(target string) (agentID string, pane string, hookRoot st } // sessionToAgentID converts a session name to agent ID format. -func sessionToAgentID(session string) string { - switch { - case session == "gt-mayor": - return "mayor" - case session == "gt-deacon": - return "deacon" - case strings.Contains(session, "-crew-"): - // gt-gastown-crew-max -> gastown/crew/max - parts := strings.Split(session, "-") - for i, p := range parts { - if p == "crew" && i > 1 && i < len(parts)-1 { - rig := strings.Join(parts[1:i], "-") - name := strings.Join(parts[i+1:], "-") - return fmt.Sprintf("%s/crew/%s", rig, name) - } - } - case strings.HasSuffix(session, "-witness"): - rig := strings.TrimPrefix(session, "gt-") - rig = strings.TrimSuffix(rig, "-witness") - return fmt.Sprintf("%s/witness", rig) - case strings.HasSuffix(session, "-refinery"): - rig := strings.TrimPrefix(session, "gt-") - rig = strings.TrimSuffix(rig, "-refinery") - return fmt.Sprintf("%s/refinery", rig) +// Uses session.ParseSessionName for consistent parsing across the codebase. +func sessionToAgentID(sessionName string) string { + identity, err := session.ParseSessionName(sessionName) + if err != nil { + // Fallback for unparseable sessions + return sessionName } - return session + return identity.Address() } // verifyBeadExists checks that the bead exists using bd show. diff --git a/internal/session/identity.go b/internal/session/identity.go new file mode 100644 index 00000000..6da6eab4 --- /dev/null +++ b/internal/session/identity.go @@ -0,0 +1,144 @@ +// Package session provides polecat session lifecycle management. +package session + +import ( + "fmt" + "strings" +) + +// Role represents the type of Gas Town agent. +type Role string + +const ( + RoleMayor Role = "mayor" + RoleDeacon Role = "deacon" + RoleWitness Role = "witness" + RoleRefinery Role = "refinery" + RoleCrew Role = "crew" + RolePolecat Role = "polecat" +) + +// AgentIdentity represents a parsed Gas Town agent identity. +type AgentIdentity struct { + Role Role // mayor, deacon, witness, refinery, crew, polecat + Rig string // empty for mayor/deacon + Name string // crew/polecat name (empty for mayor/deacon/witness/refinery) +} + +// ParseSessionName parses a tmux session name into an AgentIdentity. +// +// Session name formats: +// - gt-mayor → Role: mayor +// - gt-deacon → Role: deacon +// - gt--witness → Role: witness, Rig: +// - gt--refinery → Role: refinery, Rig: +// - gt--crew- → Role: crew, Rig: , Name: +// - gt-- → Role: polecat, Rig: , Name: +// +// For polecat sessions without a crew marker, the last segment after the rig +// is assumed to be the polecat name. This works for simple rig names but may +// be ambiguous for rig names containing hyphens. +func ParseSessionName(session string) (*AgentIdentity, error) { + if !strings.HasPrefix(session, Prefix) { + return nil, fmt.Errorf("invalid session name %q: missing %q prefix", session, Prefix) + } + + suffix := strings.TrimPrefix(session, Prefix) + if suffix == "" { + return nil, fmt.Errorf("invalid session name %q: empty after prefix", session) + } + + // Check for global roles first (no rig) + switch suffix { + case "mayor": + return &AgentIdentity{Role: RoleMayor}, nil + case "deacon": + return &AgentIdentity{Role: RoleDeacon}, nil + } + + // Parse rig-based roles + parts := strings.Split(suffix, "-") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid session name %q: expected rig-role format", session) + } + + // Check for witness/refinery (suffix markers) + if parts[len(parts)-1] == "witness" { + rig := strings.Join(parts[:len(parts)-1], "-") + return &AgentIdentity{Role: RoleWitness, Rig: rig}, nil + } + if parts[len(parts)-1] == "refinery" { + rig := strings.Join(parts[:len(parts)-1], "-") + return &AgentIdentity{Role: RoleRefinery, Rig: rig}, nil + } + + // Check for crew (marker in middle) + for i, p := range parts { + if p == "crew" && i > 0 && i < len(parts)-1 { + rig := strings.Join(parts[:i], "-") + name := strings.Join(parts[i+1:], "-") + return &AgentIdentity{Role: RoleCrew, Rig: rig, Name: name}, nil + } + } + + // Default to polecat: rig is everything except the last segment + if len(parts) < 2 { + return nil, fmt.Errorf("invalid session name %q: cannot determine rig/name", session) + } + rig := strings.Join(parts[:len(parts)-1], "-") + name := parts[len(parts)-1] + return &AgentIdentity{Role: RolePolecat, Rig: rig, Name: name}, nil +} + +// SessionName returns the tmux session name for this identity. +func (a *AgentIdentity) SessionName() string { + switch a.Role { + case RoleMayor: + return MayorSessionName() + case RoleDeacon: + return DeaconSessionName() + case RoleWitness: + return WitnessSessionName(a.Rig) + case RoleRefinery: + return RefinerySessionName(a.Rig) + case RoleCrew: + return CrewSessionName(a.Rig, a.Name) + case RolePolecat: + return PolecatSessionName(a.Rig, a.Name) + default: + return "" + } +} + +// Address returns the mail-style address for this identity. +// Examples: +// - mayor → "mayor" +// - deacon → "deacon" +// - witness → "gastown/witness" +// - refinery → "gastown/refinery" +// - crew → "gastown/crew/max" +// - polecat → "gastown/polecats/Toast" +func (a *AgentIdentity) Address() string { + switch a.Role { + case RoleMayor: + return "mayor" + case RoleDeacon: + return "deacon" + case RoleWitness: + return fmt.Sprintf("%s/witness", a.Rig) + case RoleRefinery: + return fmt.Sprintf("%s/refinery", a.Rig) + case RoleCrew: + return fmt.Sprintf("%s/crew/%s", a.Rig, a.Name) + case RolePolecat: + return fmt.Sprintf("%s/polecats/%s", a.Rig, a.Name) + default: + return "" + } +} + +// GTRole returns the GT_ROLE environment variable format. +// This is the same as Address() for most roles. +func (a *AgentIdentity) GTRole() string { + return a.Address() +} diff --git a/internal/session/identity_test.go b/internal/session/identity_test.go new file mode 100644 index 00000000..b2a7c743 --- /dev/null +++ b/internal/session/identity_test.go @@ -0,0 +1,252 @@ +package session + +import ( + "testing" +) + +func TestParseSessionName(t *testing.T) { + tests := []struct { + name string + session string + wantRole Role + wantRig string + wantName string + wantErr bool + }{ + // Global roles (no rig) + { + name: "mayor", + session: "gt-mayor", + wantRole: RoleMayor, + }, + { + name: "deacon", + session: "gt-deacon", + wantRole: RoleDeacon, + }, + + // Witness (simple rig) + { + name: "witness simple rig", + session: "gt-gastown-witness", + wantRole: RoleWitness, + wantRig: "gastown", + }, + { + name: "witness hyphenated rig", + session: "gt-foo-bar-witness", + wantRole: RoleWitness, + wantRig: "foo-bar", + }, + + // Refinery (simple rig) + { + name: "refinery simple rig", + session: "gt-gastown-refinery", + wantRole: RoleRefinery, + wantRig: "gastown", + }, + { + name: "refinery hyphenated rig", + session: "gt-my-project-refinery", + wantRole: RoleRefinery, + wantRig: "my-project", + }, + + // Crew (with marker) + { + name: "crew simple", + session: "gt-gastown-crew-max", + wantRole: RoleCrew, + wantRig: "gastown", + wantName: "max", + }, + { + name: "crew hyphenated rig", + session: "gt-foo-bar-crew-alice", + wantRole: RoleCrew, + wantRig: "foo-bar", + wantName: "alice", + }, + { + name: "crew hyphenated name", + session: "gt-gastown-crew-my-worker", + wantRole: RoleCrew, + wantRig: "gastown", + wantName: "my-worker", + }, + + // Polecat (fallback) + { + name: "polecat simple", + session: "gt-gastown-morsov", + wantRole: RolePolecat, + wantRig: "gastown", + wantName: "morsov", + }, + { + name: "polecat hyphenated rig", + session: "gt-foo-bar-Toast", + wantRole: RolePolecat, + wantRig: "foo-bar", + wantName: "Toast", + }, + + // Error cases + { + name: "missing prefix", + session: "gastown-witness", + wantErr: true, + }, + { + name: "empty after prefix", + session: "gt-", + wantErr: true, + }, + { + name: "just prefix", + session: "gt-x", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSessionName(tt.session) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSessionName(%q) error = %v, wantErr %v", tt.session, err, tt.wantErr) + return + } + if err != nil { + return + } + if got.Role != tt.wantRole { + t.Errorf("ParseSessionName(%q).Role = %v, want %v", tt.session, got.Role, tt.wantRole) + } + if got.Rig != tt.wantRig { + t.Errorf("ParseSessionName(%q).Rig = %v, want %v", tt.session, got.Rig, tt.wantRig) + } + if got.Name != tt.wantName { + t.Errorf("ParseSessionName(%q).Name = %v, want %v", tt.session, got.Name, tt.wantName) + } + }) + } +} + +func TestAgentIdentity_SessionName(t *testing.T) { + tests := []struct { + name string + identity AgentIdentity + want string + }{ + { + name: "mayor", + identity: AgentIdentity{Role: RoleMayor}, + want: "gt-mayor", + }, + { + name: "deacon", + identity: AgentIdentity{Role: RoleDeacon}, + want: "gt-deacon", + }, + { + name: "witness", + identity: AgentIdentity{Role: RoleWitness, Rig: "gastown"}, + want: "gt-gastown-witness", + }, + { + name: "refinery", + identity: AgentIdentity{Role: RoleRefinery, Rig: "my-project"}, + want: "gt-my-project-refinery", + }, + { + name: "crew", + identity: AgentIdentity{Role: RoleCrew, Rig: "gastown", Name: "max"}, + want: "gt-gastown-crew-max", + }, + { + name: "polecat", + identity: AgentIdentity{Role: RolePolecat, Rig: "gastown", Name: "morsov"}, + want: "gt-gastown-morsov", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.identity.SessionName(); got != tt.want { + t.Errorf("AgentIdentity.SessionName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAgentIdentity_Address(t *testing.T) { + tests := []struct { + name string + identity AgentIdentity + want string + }{ + { + name: "mayor", + identity: AgentIdentity{Role: RoleMayor}, + want: "mayor", + }, + { + name: "deacon", + identity: AgentIdentity{Role: RoleDeacon}, + want: "deacon", + }, + { + name: "witness", + identity: AgentIdentity{Role: RoleWitness, Rig: "gastown"}, + want: "gastown/witness", + }, + { + name: "refinery", + identity: AgentIdentity{Role: RoleRefinery, Rig: "my-project"}, + want: "my-project/refinery", + }, + { + name: "crew", + identity: AgentIdentity{Role: RoleCrew, Rig: "gastown", Name: "max"}, + want: "gastown/crew/max", + }, + { + name: "polecat", + identity: AgentIdentity{Role: RolePolecat, Rig: "gastown", Name: "Toast"}, + want: "gastown/polecats/Toast", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.identity.Address(); got != tt.want { + t.Errorf("AgentIdentity.Address() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseSessionName_RoundTrip(t *testing.T) { + // Test that parsing then reconstructing gives the same result + sessions := []string{ + "gt-mayor", + "gt-deacon", + "gt-gastown-witness", + "gt-foo-bar-refinery", + "gt-gastown-crew-max", + "gt-gastown-morsov", + } + + for _, session := range sessions { + t.Run(session, func(t *testing.T) { + identity, err := ParseSessionName(session) + if err != nil { + t.Fatalf("ParseSessionName(%q) error = %v", session, err) + } + if got := identity.SessionName(); got != session { + t.Errorf("Round-trip failed: ParseSessionName(%q).SessionName() = %q", session, got) + } + }) + } +}