- Add Alias field to RigEntry struct for short display names - Limit displayed rigs to 3 (was unlimited, causing overflow) - Use alias in statusline when configured (e.g., gcr instead of google_cookie_retrieval) - Show +N overflow indicator when more rigs exist Closes: hq-5j33zz
820 lines
23 KiB
Go
820 lines
23 KiB
Go
package cmd
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/gastown/internal/beads"
|
||
"github.com/steveyegge/gastown/internal/config"
|
||
"github.com/steveyegge/gastown/internal/mail"
|
||
"github.com/steveyegge/gastown/internal/tmux"
|
||
"github.com/steveyegge/gastown/internal/workspace"
|
||
)
|
||
|
||
var (
|
||
statusLineSession string
|
||
)
|
||
|
||
var statusLineCmd = &cobra.Command{
|
||
Use: "status-line",
|
||
Short: "Output status line content for tmux (internal use)",
|
||
Hidden: true, // Internal command called by tmux
|
||
RunE: runStatusLine,
|
||
}
|
||
|
||
func init() {
|
||
rootCmd.AddCommand(statusLineCmd)
|
||
statusLineCmd.Flags().StringVar(&statusLineSession, "session", "", "Tmux session name")
|
||
}
|
||
|
||
func runStatusLine(cmd *cobra.Command, args []string) error {
|
||
t := tmux.NewTmux()
|
||
|
||
// Get session environment
|
||
var rigName, polecat, crew, issue, role string
|
||
|
||
if statusLineSession != "" {
|
||
// Non-fatal: missing env vars are handled gracefully below
|
||
rigName, _ = t.GetEnvironment(statusLineSession, "GT_RIG")
|
||
polecat, _ = t.GetEnvironment(statusLineSession, "GT_POLECAT")
|
||
crew, _ = t.GetEnvironment(statusLineSession, "GT_CREW")
|
||
issue, _ = t.GetEnvironment(statusLineSession, "GT_ISSUE")
|
||
role, _ = t.GetEnvironment(statusLineSession, "GT_ROLE")
|
||
} else {
|
||
// Fallback to process environment
|
||
rigName = os.Getenv("GT_RIG")
|
||
polecat = os.Getenv("GT_POLECAT")
|
||
crew = os.Getenv("GT_CREW")
|
||
issue = os.Getenv("GT_ISSUE")
|
||
role = os.Getenv("GT_ROLE")
|
||
}
|
||
|
||
// Get session names for comparison
|
||
mayorSession := getMayorSessionName()
|
||
deaconSession := getDeaconSessionName()
|
||
|
||
// Determine identity and output based on role
|
||
if role == "mayor" || statusLineSession == mayorSession {
|
||
return runMayorStatusLine(t)
|
||
}
|
||
|
||
// Deacon status line
|
||
if role == "deacon" || statusLineSession == deaconSession {
|
||
return runDeaconStatusLine(t)
|
||
}
|
||
|
||
// Witness status line (session naming: gt-<rig>-witness)
|
||
if role == "witness" || strings.HasSuffix(statusLineSession, "-witness") {
|
||
return runWitnessStatusLine(t, rigName)
|
||
}
|
||
|
||
// Refinery status line
|
||
if role == "refinery" || strings.HasSuffix(statusLineSession, "-refinery") {
|
||
return runRefineryStatusLine(t, rigName)
|
||
}
|
||
|
||
// Crew/Polecat status line
|
||
return runWorkerStatusLine(t, statusLineSession, rigName, polecat, crew, issue)
|
||
}
|
||
|
||
// runWorkerStatusLine outputs status for crew or polecat sessions.
|
||
func runWorkerStatusLine(t *tmux.Tmux, session, rigName, polecat, crew, issue string) error {
|
||
// Determine agent type and identity
|
||
var icon, identity string
|
||
if polecat != "" {
|
||
icon = AgentTypeIcons[AgentPolecat]
|
||
identity = fmt.Sprintf("%s/%s", rigName, polecat)
|
||
} else if crew != "" {
|
||
icon = AgentTypeIcons[AgentCrew]
|
||
identity = fmt.Sprintf("%s/crew/%s", rigName, crew)
|
||
}
|
||
|
||
// Get pane's working directory to find workspace
|
||
var townRoot string
|
||
if session != "" {
|
||
paneDir, err := t.GetPaneWorkDir(session)
|
||
if err == nil && paneDir != "" {
|
||
townRoot, _ = workspace.Find(paneDir)
|
||
}
|
||
}
|
||
|
||
// Build status parts
|
||
var parts []string
|
||
|
||
// Priority 1: Check for hooked work (use rig beads)
|
||
hookedWork := ""
|
||
if identity != "" && rigName != "" && townRoot != "" {
|
||
rigBeadsDir := filepath.Join(townRoot, rigName, "mayor", "rig")
|
||
hookedWork = getHookedWork(identity, 40, rigBeadsDir)
|
||
}
|
||
|
||
// Priority 2: Fall back to GT_ISSUE env var or in_progress beads
|
||
currentWork := issue
|
||
if currentWork == "" && hookedWork == "" && session != "" {
|
||
currentWork = getCurrentWork(t, session, identity, 40)
|
||
}
|
||
|
||
// Show hooked work (takes precedence)
|
||
if hookedWork != "" {
|
||
if icon != "" {
|
||
parts = append(parts, fmt.Sprintf("%s 🪝 %s", icon, hookedWork))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("🪝 %s", hookedWork))
|
||
}
|
||
} else if currentWork != "" {
|
||
// Fall back to current work (in_progress)
|
||
if icon != "" {
|
||
parts = append(parts, fmt.Sprintf("%s %s", icon, currentWork))
|
||
} else {
|
||
parts = append(parts, currentWork)
|
||
}
|
||
} else if icon != "" {
|
||
parts = append(parts, icon)
|
||
}
|
||
|
||
// Mail preview - only show if hook is empty
|
||
if hookedWork == "" && identity != "" && townRoot != "" {
|
||
unread, subject := getMailPreviewWithRoot(identity, 45, townRoot)
|
||
if unread > 0 {
|
||
if subject != "" {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %s", subject))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Output
|
||
if len(parts) > 0 {
|
||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func runMayorStatusLine(t *tmux.Tmux) error {
|
||
// Count active sessions by listing tmux sessions
|
||
sessions, err := t.ListSessions()
|
||
if err != nil {
|
||
return nil // Silent fail
|
||
}
|
||
|
||
// Get town root from mayor pane's working directory
|
||
var townRoot string
|
||
mayorSession := getMayorSessionName()
|
||
paneDir, err := t.GetPaneWorkDir(mayorSession)
|
||
if err == nil && paneDir != "" {
|
||
townRoot, _ = workspace.Find(paneDir)
|
||
}
|
||
|
||
// Load registered rigs to validate against and get aliases
|
||
registeredRigs := make(map[string]bool)
|
||
rigAliases := make(map[string]string)
|
||
if townRoot != "" {
|
||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||
if rigsConfig, err := config.LoadRigsConfig(rigsConfigPath); err == nil {
|
||
for rigName, entry := range rigsConfig.Rigs {
|
||
registeredRigs[rigName] = true
|
||
if entry.Alias != "" {
|
||
rigAliases[rigName] = entry.Alias
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Track per-rig status for LED indicators and sorting
|
||
type rigStatus struct {
|
||
hasWitness bool
|
||
hasRefinery bool
|
||
opState string // "OPERATIONAL", "PARKED", or "DOCKED"
|
||
}
|
||
rigStatuses := make(map[string]*rigStatus)
|
||
|
||
// Initialize for all registered rigs
|
||
for rigName := range registeredRigs {
|
||
rigStatuses[rigName] = &rigStatus{}
|
||
}
|
||
|
||
// Track per-agent-type health (working/zombie counts)
|
||
type agentHealth struct {
|
||
total int
|
||
working int
|
||
}
|
||
healthByType := map[AgentType]*agentHealth{
|
||
AgentWitness: {},
|
||
AgentRefinery: {},
|
||
}
|
||
|
||
// Track deacon presence (just icon, no count)
|
||
hasDeacon := false
|
||
|
||
// Single pass: track rig status AND agent health
|
||
for _, s := range sessions {
|
||
agent := categorizeSession(s)
|
||
if agent == nil {
|
||
continue
|
||
}
|
||
|
||
// Track rig-level status (witness/refinery presence)
|
||
// Polecats are not tracked in tmux - they're a GC concern, not a display concern
|
||
if agent.Rig != "" && registeredRigs[agent.Rig] {
|
||
if rigStatuses[agent.Rig] == nil {
|
||
rigStatuses[agent.Rig] = &rigStatus{}
|
||
}
|
||
switch agent.Type {
|
||
case AgentWitness:
|
||
rigStatuses[agent.Rig].hasWitness = true
|
||
case AgentRefinery:
|
||
rigStatuses[agent.Rig].hasRefinery = true
|
||
}
|
||
}
|
||
|
||
// Track agent health (skip Mayor and Crew)
|
||
if health := healthByType[agent.Type]; health != nil {
|
||
health.total++
|
||
// Detect working state via ✻ symbol
|
||
if isSessionWorking(t, s) {
|
||
health.working++
|
||
}
|
||
}
|
||
|
||
// Track deacon presence (just the icon, no count)
|
||
if agent.Type == AgentDeacon {
|
||
hasDeacon = true
|
||
}
|
||
}
|
||
|
||
// Get operational state for each rig
|
||
for rigName, status := range rigStatuses {
|
||
opState, _ := getRigOperationalState(townRoot, rigName)
|
||
if opState == "PARKED" || opState == "DOCKED" {
|
||
status.opState = opState
|
||
} else {
|
||
status.opState = "OPERATIONAL"
|
||
}
|
||
}
|
||
|
||
// Build status
|
||
var parts []string
|
||
|
||
// Add per-agent-type health in consistent order
|
||
// Format: "1/3 👁️" = 1 working out of 3 total
|
||
// Only show agent types that have sessions
|
||
// Note: Polecats excluded - idle state is misleading noise
|
||
// Deacon gets just an icon (no count) - shown separately below
|
||
agentOrder := []AgentType{AgentWitness, AgentRefinery}
|
||
var agentParts []string
|
||
for _, agentType := range agentOrder {
|
||
health := healthByType[agentType]
|
||
if health.total == 0 {
|
||
continue
|
||
}
|
||
icon := AgentTypeIcons[agentType]
|
||
agentParts = append(agentParts, fmt.Sprintf("%d/%d %s", health.working, health.total, icon))
|
||
}
|
||
if len(agentParts) > 0 {
|
||
parts = append(parts, strings.Join(agentParts, " "))
|
||
}
|
||
|
||
// Add deacon icon if running (just presence, no count)
|
||
if hasDeacon {
|
||
parts = append(parts, AgentTypeIcons[AgentDeacon])
|
||
}
|
||
|
||
// Build rig status display with LED indicators
|
||
// 🟢 = both witness and refinery running (fully active)
|
||
// 🟡 = one of witness/refinery running (partially active)
|
||
// 🅿️ = parked (nothing running, intentionally paused)
|
||
// 🛑 = docked (nothing running, global shutdown)
|
||
// ⚫ = operational but nothing running (unexpected state)
|
||
|
||
// Create sortable rig list
|
||
type rigInfo struct {
|
||
name string
|
||
alias string
|
||
status *rigStatus
|
||
}
|
||
var rigs []rigInfo
|
||
for rigName, status := range rigStatuses {
|
||
ri := rigInfo{name: rigName, status: status}
|
||
if alias, ok := rigAliases[rigName]; ok {
|
||
ri.alias = alias
|
||
}
|
||
rigs = append(rigs, ri)
|
||
}
|
||
|
||
// Sort by: 1) running state, 2) operational state, 3) alphabetical
|
||
sort.Slice(rigs, func(i, j int) bool {
|
||
isRunningI := rigs[i].status.hasWitness || rigs[i].status.hasRefinery
|
||
isRunningJ := rigs[j].status.hasWitness || rigs[j].status.hasRefinery
|
||
|
||
// Primary sort: running rigs before non-running rigs
|
||
if isRunningI != isRunningJ {
|
||
return isRunningI
|
||
}
|
||
|
||
// Secondary sort: operational state (for non-running rigs: OPERATIONAL < PARKED < DOCKED)
|
||
stateOrder := map[string]int{"OPERATIONAL": 0, "PARKED": 1, "DOCKED": 2}
|
||
stateI := stateOrder[rigs[i].status.opState]
|
||
stateJ := stateOrder[rigs[j].status.opState]
|
||
if stateI != stateJ {
|
||
return stateI < stateJ
|
||
}
|
||
|
||
// Tertiary sort: alphabetical
|
||
return rigs[i].name < rigs[j].name
|
||
})
|
||
|
||
// Build display with group separators
|
||
// Limit to maxRigs to prevent statusline overflow
|
||
maxRigs := 3
|
||
var rigParts []string
|
||
var lastGroup string
|
||
displayCount := 0
|
||
for _, rig := range rigs {
|
||
if displayCount >= maxRigs {
|
||
break
|
||
}
|
||
|
||
isRunning := rig.status.hasWitness || rig.status.hasRefinery
|
||
var currentGroup string
|
||
if isRunning {
|
||
currentGroup = "running"
|
||
} else {
|
||
currentGroup = "idle-" + rig.status.opState
|
||
}
|
||
|
||
// Add separator when group changes (running -> non-running, or different opStates within non-running)
|
||
if lastGroup != "" && lastGroup != currentGroup {
|
||
rigParts = append(rigParts, "|")
|
||
}
|
||
lastGroup = currentGroup
|
||
|
||
status := rig.status
|
||
var led string
|
||
|
||
// Check if processes are running first (regardless of operational state)
|
||
if status.hasWitness && status.hasRefinery {
|
||
led = "🟢" // Both running - fully active
|
||
} else if status.hasWitness || status.hasRefinery {
|
||
led = "🟡" // One running - partially active
|
||
} else {
|
||
// Nothing running - show operational state
|
||
switch status.opState {
|
||
case "PARKED":
|
||
led = "🅿️" // Parked - intentionally paused
|
||
case "DOCKED":
|
||
led = "🛑" // Docked - global shutdown
|
||
default:
|
||
led = "⚫" // Operational but nothing running
|
||
}
|
||
}
|
||
|
||
// All icons get 1 space, Park gets 2
|
||
space := " "
|
||
if led == "🅿️" {
|
||
space = " "
|
||
}
|
||
|
||
// Use alias if available, otherwise use full name
|
||
displayName := rig.name
|
||
if rig.alias != "" {
|
||
displayName = rig.alias
|
||
}
|
||
rigParts = append(rigParts, led+space+displayName)
|
||
displayCount++
|
||
}
|
||
|
||
// Show overflow indicator if there are more rigs
|
||
if len(rigs) > maxRigs {
|
||
rigParts = append(rigParts, fmt.Sprintf("+%d", len(rigs)-maxRigs))
|
||
}
|
||
|
||
if len(rigParts) > 0 {
|
||
parts = append(parts, strings.Join(rigParts, " "))
|
||
}
|
||
|
||
// Priority 1: Check for hooked work (town beads for mayor)
|
||
hookedWork := ""
|
||
if townRoot != "" {
|
||
hookedWork = getHookedWork("mayor", 40, townRoot)
|
||
}
|
||
if hookedWork != "" {
|
||
parts = append(parts, fmt.Sprintf("🪝 %s", hookedWork))
|
||
} else if townRoot != "" {
|
||
// Priority 2: Fall back to mail preview
|
||
unread, subject := getMailPreviewWithRoot("mayor/", 45, townRoot)
|
||
if unread > 0 {
|
||
if subject != "" {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %s", subject))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||
return nil
|
||
}
|
||
|
||
// runDeaconStatusLine outputs status for the deacon session.
|
||
// Shows: active rigs, polecat count, hook or mail preview
|
||
func runDeaconStatusLine(t *tmux.Tmux) error {
|
||
// Count active rigs and polecats
|
||
sessions, err := t.ListSessions()
|
||
if err != nil {
|
||
return nil // Silent fail
|
||
}
|
||
|
||
// Get town root from deacon pane's working directory
|
||
var townRoot string
|
||
deaconSession := getDeaconSessionName()
|
||
paneDir, err := t.GetPaneWorkDir(deaconSession)
|
||
if err == nil && paneDir != "" {
|
||
townRoot, _ = workspace.Find(paneDir)
|
||
}
|
||
|
||
// Load registered rigs to validate against
|
||
registeredRigs := make(map[string]bool)
|
||
if townRoot != "" {
|
||
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||
if rigsConfig, err := config.LoadRigsConfig(rigsConfigPath); err == nil {
|
||
for rigName := range rigsConfig.Rigs {
|
||
registeredRigs[rigName] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
rigs := make(map[string]bool)
|
||
for _, s := range sessions {
|
||
agent := categorizeSession(s)
|
||
if agent == nil {
|
||
continue
|
||
}
|
||
// Only count registered rigs
|
||
if agent.Rig != "" && registeredRigs[agent.Rig] {
|
||
rigs[agent.Rig] = true
|
||
}
|
||
}
|
||
rigCount := len(rigs)
|
||
|
||
// Build status
|
||
// Note: Polecats excluded - they're ephemeral and idle detection is a GC concern
|
||
var parts []string
|
||
parts = append(parts, fmt.Sprintf("%d rigs", rigCount))
|
||
|
||
// Priority 1: Check for hooked work (town beads for deacon)
|
||
hookedWork := ""
|
||
if townRoot != "" {
|
||
hookedWork = getHookedWork("deacon", 35, townRoot)
|
||
}
|
||
if hookedWork != "" {
|
||
parts = append(parts, fmt.Sprintf("🪝 %s", hookedWork))
|
||
} else if townRoot != "" {
|
||
// Priority 2: Fall back to mail preview
|
||
unread, subject := getMailPreviewWithRoot("deacon/", 40, townRoot)
|
||
if unread > 0 {
|
||
if subject != "" {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %s", subject))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||
return nil
|
||
}
|
||
|
||
// runWitnessStatusLine outputs status for a witness session.
|
||
// Shows: crew count, hook or mail preview
|
||
// Note: Polecats excluded - they're ephemeral and idle detection is a GC concern
|
||
func runWitnessStatusLine(t *tmux.Tmux, rigName string) error {
|
||
if rigName == "" {
|
||
// Try to extract from session name: gt-<rig>-witness
|
||
if strings.HasSuffix(statusLineSession, "-witness") && strings.HasPrefix(statusLineSession, "gt-") {
|
||
rigName = strings.TrimPrefix(strings.TrimSuffix(statusLineSession, "-witness"), "gt-")
|
||
}
|
||
}
|
||
|
||
// Get town root from witness pane's working directory
|
||
var townRoot string
|
||
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
|
||
paneDir, err := t.GetPaneWorkDir(sessionName)
|
||
if err == nil && paneDir != "" {
|
||
townRoot, _ = workspace.Find(paneDir)
|
||
}
|
||
|
||
// Count crew in this rig (crew are persistent, worth tracking)
|
||
sessions, err := t.ListSessions()
|
||
if err != nil {
|
||
return nil // Silent fail
|
||
}
|
||
|
||
crewCount := 0
|
||
for _, s := range sessions {
|
||
agent := categorizeSession(s)
|
||
if agent == nil {
|
||
continue
|
||
}
|
||
if agent.Rig == rigName && agent.Type == AgentCrew {
|
||
crewCount++
|
||
}
|
||
}
|
||
|
||
identity := fmt.Sprintf("%s/witness", rigName)
|
||
|
||
// Build status
|
||
var parts []string
|
||
if crewCount > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d crew", crewCount))
|
||
}
|
||
|
||
// Priority 1: Check for hooked work (rig beads for witness)
|
||
hookedWork := ""
|
||
if townRoot != "" && rigName != "" {
|
||
rigBeadsDir := filepath.Join(townRoot, rigName, "mayor", "rig")
|
||
hookedWork = getHookedWork(identity, 30, rigBeadsDir)
|
||
}
|
||
if hookedWork != "" {
|
||
parts = append(parts, fmt.Sprintf("🪝 %s", hookedWork))
|
||
} else if townRoot != "" {
|
||
// Priority 2: Fall back to mail preview
|
||
unread, subject := getMailPreviewWithRoot(identity, 35, townRoot)
|
||
if unread > 0 {
|
||
if subject != "" {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %s", subject))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||
return nil
|
||
}
|
||
|
||
// runRefineryStatusLine outputs status for a refinery session.
|
||
// Shows: MQ length, current item, hook or mail preview
|
||
func runRefineryStatusLine(t *tmux.Tmux, rigName string) error {
|
||
if rigName == "" {
|
||
// Try to extract from session name: gt-<rig>-refinery
|
||
if strings.HasPrefix(statusLineSession, "gt-") && strings.HasSuffix(statusLineSession, "-refinery") {
|
||
rigName = strings.TrimPrefix(statusLineSession, "gt-")
|
||
rigName = strings.TrimSuffix(rigName, "-refinery")
|
||
}
|
||
}
|
||
|
||
if rigName == "" {
|
||
fmt.Printf("%s ? |", AgentTypeIcons[AgentRefinery])
|
||
return nil
|
||
}
|
||
|
||
// Get town root from refinery pane's working directory
|
||
var townRoot string
|
||
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
|
||
paneDir, err := t.GetPaneWorkDir(sessionName)
|
||
if err == nil && paneDir != "" {
|
||
townRoot, _ = workspace.Find(paneDir)
|
||
}
|
||
|
||
// Get refinery manager using shared helper
|
||
mgr, _, _, err := getRefineryManager(rigName)
|
||
if err != nil {
|
||
// Fallback to simple status if we can't access refinery
|
||
fmt.Printf("%s MQ: ? |", AgentTypeIcons[AgentRefinery])
|
||
return nil
|
||
}
|
||
|
||
// Get queue
|
||
queue, err := mgr.Queue()
|
||
if err != nil {
|
||
// Fallback to simple status if we can't read queue
|
||
fmt.Printf("%s MQ: ? |", AgentTypeIcons[AgentRefinery])
|
||
return nil
|
||
}
|
||
|
||
// Count pending items and find current item
|
||
pending := 0
|
||
var currentItem string
|
||
for _, item := range queue {
|
||
if item.Position == 0 && item.MR != nil {
|
||
// Currently processing - show issue ID
|
||
currentItem = item.MR.IssueID
|
||
} else {
|
||
pending++
|
||
}
|
||
}
|
||
|
||
identity := fmt.Sprintf("%s/refinery", rigName)
|
||
|
||
// Build status
|
||
var parts []string
|
||
if currentItem != "" {
|
||
parts = append(parts, fmt.Sprintf("merging %s", currentItem))
|
||
if pending > 0 {
|
||
parts = append(parts, fmt.Sprintf("+%d queued", pending))
|
||
}
|
||
} else if pending > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d queued", pending))
|
||
} else {
|
||
parts = append(parts, "idle")
|
||
}
|
||
|
||
// Priority 1: Check for hooked work (rig beads for refinery)
|
||
hookedWork := ""
|
||
if townRoot != "" && rigName != "" {
|
||
rigBeadsDir := filepath.Join(townRoot, rigName, "mayor", "rig")
|
||
hookedWork = getHookedWork(identity, 25, rigBeadsDir)
|
||
}
|
||
if hookedWork != "" {
|
||
parts = append(parts, fmt.Sprintf("🪝 %s", hookedWork))
|
||
} else if townRoot != "" {
|
||
// Priority 2: Fall back to mail preview
|
||
unread, subject := getMailPreviewWithRoot(identity, 30, townRoot)
|
||
if unread > 0 {
|
||
if subject != "" {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %s", subject))
|
||
} else {
|
||
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Print(strings.Join(parts, " | ") + " |")
|
||
return nil
|
||
}
|
||
|
||
// isSessionWorking detects if a Claude Code session is actively working.
|
||
// Returns true if the ✻ symbol is visible in the pane (indicates Claude is processing).
|
||
// Returns false for idle sessions (showing ❯ prompt) or if state cannot be determined.
|
||
func isSessionWorking(t *tmux.Tmux, session string) bool {
|
||
// Capture last few lines of the pane
|
||
lines, err := t.CapturePaneLines(session, 5)
|
||
if err != nil || len(lines) == 0 {
|
||
return false
|
||
}
|
||
|
||
// Check all captured lines for the working indicator
|
||
// ✻ appears in Claude's status line when actively processing
|
||
for _, line := range lines {
|
||
if strings.Contains(line, "✻") {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// getUnreadMailCount returns unread mail count for an identity.
|
||
// Fast path - returns 0 on any error.
|
||
func getUnreadMailCount(identity string) int {
|
||
// Find workspace
|
||
workDir, err := findMailWorkDir()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
// Create mailbox using beads
|
||
mailbox := mail.NewMailboxBeads(identity, workDir)
|
||
|
||
// Get count
|
||
_, unread, err := mailbox.Count()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
return unread
|
||
}
|
||
|
||
// getMailPreview returns unread count and a truncated subject of the first unread message.
|
||
// Returns (count, subject) where subject is empty if no unread mail.
|
||
func getMailPreview(identity string, maxLen int) (int, string) {
|
||
workDir, err := findMailWorkDir()
|
||
if err != nil {
|
||
return 0, ""
|
||
}
|
||
|
||
mailbox := mail.NewMailboxBeads(identity, workDir)
|
||
|
||
// Get unread messages
|
||
messages, err := mailbox.ListUnread()
|
||
if err != nil || len(messages) == 0 {
|
||
return 0, ""
|
||
}
|
||
|
||
// Get first message subject, truncated
|
||
subject := messages[0].Subject
|
||
if len(subject) > maxLen {
|
||
subject = subject[:maxLen-1] + "…"
|
||
}
|
||
|
||
return len(messages), subject
|
||
}
|
||
|
||
// getMailPreviewWithRoot is like getMailPreview but uses an explicit town root.
|
||
func getMailPreviewWithRoot(identity string, maxLen int, townRoot string) (int, string) {
|
||
// Use NewMailboxFromAddress to normalize identity (e.g., gastown/crew/gus -> gastown/gus)
|
||
mailbox := mail.NewMailboxFromAddress(identity, townRoot)
|
||
|
||
// Get unread messages
|
||
messages, err := mailbox.ListUnread()
|
||
if err != nil || len(messages) == 0 {
|
||
return 0, ""
|
||
}
|
||
|
||
// Get first message subject, truncated
|
||
subject := messages[0].Subject
|
||
if len(subject) > maxLen {
|
||
subject = subject[:maxLen-1] + "…"
|
||
}
|
||
|
||
return len(messages), subject
|
||
}
|
||
|
||
// getHookedWork returns a truncated title of the hooked bead for an agent.
|
||
// Returns empty string if nothing is hooked.
|
||
// beadsDir should be the directory containing .beads (for rig-level) or
|
||
// empty to use the town root (for town-level roles).
|
||
func getHookedWork(identity string, maxLen int, beadsDir string) string {
|
||
// Guard: identity must be non-empty to filter by assignee.
|
||
// Without identity, the query would return ALL hooked beads regardless of assignee.
|
||
if identity == "" {
|
||
return ""
|
||
}
|
||
|
||
// If no beadsDir specified, use town root
|
||
if beadsDir == "" {
|
||
var err error
|
||
beadsDir, err = findMailWorkDir()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
}
|
||
|
||
b := beads.New(beadsDir)
|
||
|
||
// Query for hooked beads assigned to this agent
|
||
hookedBeads, err := b.List(beads.ListOptions{
|
||
Status: beads.StatusHooked,
|
||
Assignee: identity,
|
||
Priority: -1,
|
||
})
|
||
if err != nil || len(hookedBeads) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Return first hooked bead's ID and title, truncated
|
||
bead := hookedBeads[0]
|
||
display := fmt.Sprintf("%s: %s", bead.ID, bead.Title)
|
||
if len(display) > maxLen {
|
||
display = display[:maxLen-1] + "…"
|
||
}
|
||
return display
|
||
}
|
||
|
||
// getCurrentWork returns a truncated title of the first in_progress issue assigned to this agent.
|
||
// Uses the pane's working directory to find the beads.
|
||
func getCurrentWork(t *tmux.Tmux, session, identity string, maxLen int) string {
|
||
// Guard: identity must be non-empty to filter by assignee.
|
||
// Without identity, the query would return ALL in_progress beads regardless of assignee.
|
||
if identity == "" {
|
||
return ""
|
||
}
|
||
|
||
// Get the pane's working directory
|
||
workDir, err := t.GetPaneWorkDir(session)
|
||
if err != nil || workDir == "" {
|
||
return ""
|
||
}
|
||
|
||
// Check if there's a .beads directory
|
||
beadsDir := filepath.Join(workDir, ".beads")
|
||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||
return ""
|
||
}
|
||
|
||
// Query beads for in_progress issues assigned to this agent
|
||
b := beads.New(workDir)
|
||
issues, err := b.List(beads.ListOptions{
|
||
Status: "in_progress",
|
||
Assignee: identity,
|
||
Priority: -1,
|
||
})
|
||
if err != nil || len(issues) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Return first issue's ID and title, truncated
|
||
issue := issues[0]
|
||
display := fmt.Sprintf("%s: %s", issue.ID, issue.Title)
|
||
if len(display) > maxLen {
|
||
display = display[:maxLen-1] + "…"
|
||
}
|
||
return display
|
||
}
|