Files
gastown/internal/cmd/status.go
gastown/crew/joe 1f44482ad0 fix: remove observable states from agent_state (discover, don't track)
The agent_state field was recording observable state like "running",
"dead", "idle" which violated the "Discover, Don't Track" principle.
This caused stale state bugs where agents were marked "dead" in beads
but actually running in tmux.

Changes:
- Remove daemon's checkStaleAgents() which marked agents "dead"
- Simplify ensureXxxRunning() to use tmux.IsClaudeRunning() directly
- Remove reportAgentState() calls from gt prime and gt handoff
- Add SetHookBead/ClearHookBead helpers that don't update agent_state
- Use ClearHookBead in gt done and gt unsling
- Simplify gt status to derive state from tmux, not bead

Non-observable states (stuck, awaiting-gate, muted, paused) are still
set because they represent intentional agent decisions that can't be
discovered from tmux state.

Fixes: gt-zecmc

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:32:02 -08:00

1235 lines
36 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
"golang.org/x/term"
)
var statusJSON bool
var statusFast bool
var statusWatch bool
var statusInterval int
var statusVerbose bool
var statusCmd = &cobra.Command{
Use: "status",
Aliases: []string{"stat"},
GroupID: GroupDiag,
Short: "Show overall town status",
Long: `Display the current status of the Gas Town workspace.
Shows town name, registered rigs, active polecats, and witness status.
Use --fast to skip mail lookups for faster execution.
Use --watch to continuously refresh status at regular intervals.`,
RunE: runStatus,
}
func init() {
statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON")
statusCmd.Flags().BoolVar(&statusFast, "fast", false, "Skip mail lookups for faster execution")
statusCmd.Flags().BoolVarP(&statusWatch, "watch", "w", false, "Watch mode: refresh status continuously")
statusCmd.Flags().IntVarP(&statusInterval, "interval", "n", 2, "Refresh interval in seconds")
statusCmd.Flags().BoolVarP(&statusVerbose, "verbose", "v", false, "Show detailed multi-line output per agent")
rootCmd.AddCommand(statusCmd)
}
// TownStatus represents the overall status of the workspace.
type TownStatus struct {
Name string `json:"name"`
Location string `json:"location"`
Overseer *OverseerInfo `json:"overseer,omitempty"` // Human operator
Agents []AgentRuntime `json:"agents"` // Global agents (Mayor, Deacon)
Rigs []RigStatus `json:"rigs"`
Summary StatusSum `json:"summary"`
}
// OverseerInfo represents the human operator's identity and status.
type OverseerInfo struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
Username string `json:"username,omitempty"`
Source string `json:"source"`
UnreadMail int `json:"unread_mail"`
}
// AgentRuntime represents the runtime state of an agent.
type AgentRuntime struct {
Name string `json:"name"` // Display name (e.g., "mayor", "witness")
Address string `json:"address"` // Full address (e.g., "greenplace/witness")
Session string `json:"session"` // tmux session name
Role string `json:"role"` // Role type
Running bool `json:"running"` // Is tmux session running?
HasWork bool `json:"has_work"` // Has pinned work?
WorkTitle string `json:"work_title,omitempty"` // Title of pinned work
HookBead string `json:"hook_bead,omitempty"` // Pinned bead ID from agent bead
State string `json:"state,omitempty"` // Agent state from agent bead
UnreadMail int `json:"unread_mail"` // Number of unread messages
FirstSubject string `json:"first_subject,omitempty"` // Subject of first unread message
}
// RigStatus represents status of a single rig.
type RigStatus struct {
Name string `json:"name"`
Polecats []string `json:"polecats"`
PolecatCount int `json:"polecat_count"`
Crews []string `json:"crews"`
CrewCount int `json:"crew_count"`
HasWitness bool `json:"has_witness"`
HasRefinery bool `json:"has_refinery"`
Hooks []AgentHookInfo `json:"hooks,omitempty"`
Agents []AgentRuntime `json:"agents,omitempty"` // Runtime state of all agents in rig
MQ *MQSummary `json:"mq,omitempty"` // Merge queue summary
}
// MQSummary represents the merge queue status for a rig.
type MQSummary struct {
Pending int `json:"pending"` // Open MRs ready to merge (no blockers)
InFlight int `json:"in_flight"` // MRs currently being processed
Blocked int `json:"blocked"` // MRs waiting on dependencies
State string `json:"state"` // idle, processing, or blocked
Health string `json:"health"` // healthy, stale, or empty
}
// AgentHookInfo represents an agent's hook (pinned work) status.
type AgentHookInfo struct {
Agent string `json:"agent"` // Agent address (e.g., "greenplace/toast", "greenplace/witness")
Role string `json:"role"` // Role type (polecat, crew, witness, refinery)
HasWork bool `json:"has_work"` // Whether agent has pinned work
Molecule string `json:"molecule,omitempty"` // Attached molecule ID
Title string `json:"title,omitempty"` // Pinned bead title
}
// StatusSum provides summary counts.
type StatusSum struct {
RigCount int `json:"rig_count"`
PolecatCount int `json:"polecat_count"`
CrewCount int `json:"crew_count"`
WitnessCount int `json:"witness_count"`
RefineryCount int `json:"refinery_count"`
ActiveHooks int `json:"active_hooks"`
}
func runStatus(cmd *cobra.Command, args []string) error {
if statusWatch {
return runStatusWatch(cmd, args)
}
return runStatusOnce(cmd, args)
}
func runStatusWatch(cmd *cobra.Command, args []string) error {
if statusJSON {
return fmt.Errorf("--json and --watch cannot be used together")
}
if statusInterval <= 0 {
return fmt.Errorf("interval must be positive, got %d", statusInterval)
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigChan)
ticker := time.NewTicker(time.Duration(statusInterval) * time.Second)
defer ticker.Stop()
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
for {
if isTTY {
fmt.Print("\033[H\033[2J") // ANSI: cursor home + clear screen
}
timestamp := time.Now().Format("15:04:05")
header := fmt.Sprintf("[%s] gt status --watch (every %ds, Ctrl+C to stop)", timestamp, statusInterval)
if isTTY {
fmt.Printf("%s\n\n", style.Dim.Render(header))
} else {
fmt.Printf("%s\n\n", header)
}
if err := runStatusOnce(cmd, args); err != nil {
fmt.Printf("Error: %v\n", err)
}
select {
case <-sigChan:
if isTTY {
fmt.Println("\nStopped.")
}
return nil
case <-ticker.C:
}
}
}
func runStatusOnce(_ *cobra.Command, _ []string) error {
// Find town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Check bd daemon health and attempt restart if needed
// This is non-blocking - if daemons can't be started, we show a warning but continue
bdWarning := beads.EnsureBdDaemonHealth(townRoot)
// Load town config
townConfigPath := constants.MayorTownPath(townRoot)
townConfig, err := config.LoadTownConfig(townConfigPath)
if err != nil {
// Try to continue without config
townConfig = &config.TownConfig{Name: filepath.Base(townRoot)}
}
// Load rigs config
rigsConfigPath := constants.MayorRigsPath(townRoot)
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
// Empty config if file doesn't exist
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
// Create rig manager
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
// Create tmux instance for runtime checks
t := tmux.NewTmux()
// Pre-fetch all tmux sessions for O(1) lookup
allSessions := make(map[string]bool)
if sessions, err := t.ListSessions(); err == nil {
for _, s := range sessions {
allSessions[s] = true
}
}
// Discover rigs
rigs, err := mgr.DiscoverRigs()
if err != nil {
return fmt.Errorf("discovering rigs: %w", err)
}
// Pre-fetch agent beads across all rig-specific beads DBs.
allAgentBeads := make(map[string]*beads.Issue)
allHookBeads := make(map[string]*beads.Issue)
// Fetch town-level agent beads (Mayor, Deacon) from town beads
townBeadsPath := beads.GetTownBeadsPath(townRoot)
townBeadsClient := beads.New(townBeadsPath)
townAgentBeads, _ := townBeadsClient.ListAgentBeads()
for id, issue := range townAgentBeads {
allAgentBeads[id] = issue
}
// Fetch hook beads from town beads
var townHookIDs []string
for _, issue := range townAgentBeads {
hookID := issue.HookBead
if hookID == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
hookID = fields.HookBead
}
}
if hookID != "" {
townHookIDs = append(townHookIDs, hookID)
}
}
if len(townHookIDs) > 0 {
townHookBeads, _ := townBeadsClient.ShowMultiple(townHookIDs)
for id, issue := range townHookBeads {
allHookBeads[id] = issue
}
}
// Fetch rig-level agent beads
for _, r := range rigs {
rigBeadsPath := filepath.Join(r.Path, "mayor", "rig")
rigBeads := beads.New(rigBeadsPath)
rigAgentBeads, _ := rigBeads.ListAgentBeads()
if rigAgentBeads == nil {
continue
}
for id, issue := range rigAgentBeads {
allAgentBeads[id] = issue
}
var hookIDs []string
for _, issue := range rigAgentBeads {
// Use the HookBead field from the database column; fall back for legacy beads.
hookID := issue.HookBead
if hookID == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
hookID = fields.HookBead
}
}
if hookID != "" {
hookIDs = append(hookIDs, hookID)
}
}
if len(hookIDs) == 0 {
continue
}
hookBeads, _ := rigBeads.ShowMultiple(hookIDs)
for id, issue := range hookBeads {
allHookBeads[id] = issue
}
}
// Create mail router for inbox lookups
mailRouter := mail.NewRouter(townRoot)
// Load overseer config
var overseerInfo *OverseerInfo
if overseerConfig, err := config.LoadOrDetectOverseer(townRoot); err == nil && overseerConfig != nil {
overseerInfo = &OverseerInfo{
Name: overseerConfig.Name,
Email: overseerConfig.Email,
Username: overseerConfig.Username,
Source: overseerConfig.Source,
}
// Get overseer mail count
if mailbox, err := mailRouter.GetMailbox("overseer"); err == nil {
_, unread, _ := mailbox.Count()
overseerInfo.UnreadMail = unread
}
}
// Build status - parallel fetch global agents and rigs
status := TownStatus{
Name: townConfig.Name,
Location: townRoot,
Overseer: overseerInfo,
Rigs: make([]RigStatus, len(rigs)),
}
var wg sync.WaitGroup
// Fetch global agents in parallel with rig discovery
wg.Add(1)
go func() {
defer wg.Done()
status.Agents = discoverGlobalAgents(allSessions, allAgentBeads, allHookBeads, mailRouter, statusFast)
}()
// Process all rigs in parallel
rigActiveHooks := make([]int, len(rigs)) // Track hooks per rig for thread safety
for i, r := range rigs {
wg.Add(1)
go func(idx int, r *rig.Rig) {
defer wg.Done()
rs := RigStatus{
Name: r.Name,
Polecats: r.Polecats,
PolecatCount: len(r.Polecats),
HasWitness: r.HasWitness,
HasRefinery: r.HasRefinery,
}
// Count crew workers
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
if workers, err := crewMgr.List(); err == nil {
for _, w := range workers {
rs.Crews = append(rs.Crews, w.Name)
}
rs.CrewCount = len(workers)
}
// Discover hooks for all agents in this rig
rs.Hooks = discoverRigHooks(r, rs.Crews)
activeHooks := 0
for _, hook := range rs.Hooks {
if hook.HasWork {
activeHooks++
}
}
rigActiveHooks[idx] = activeHooks
// Discover runtime state for all agents in this rig
rs.Agents = discoverRigAgents(allSessions, r, rs.Crews, allAgentBeads, allHookBeads, mailRouter, statusFast)
// Get MQ summary if rig has a refinery
rs.MQ = getMQSummary(r)
status.Rigs[idx] = rs
}(i, r)
}
wg.Wait()
// Aggregate summary (after parallel work completes)
for i, rs := range status.Rigs {
status.Summary.PolecatCount += rs.PolecatCount
status.Summary.CrewCount += rs.CrewCount
status.Summary.ActiveHooks += rigActiveHooks[i]
if rs.HasWitness {
status.Summary.WitnessCount++
}
if rs.HasRefinery {
status.Summary.RefineryCount++
}
}
status.Summary.RigCount = len(rigs)
// Output
if statusJSON {
return outputStatusJSON(status)
}
if err := outputStatusText(status); err != nil {
return err
}
// Show bd daemon warning at the end if there were issues
if bdWarning != "" {
fmt.Printf("%s %s\n", style.Warning.Render("⚠"), bdWarning)
fmt.Printf(" Run 'bd daemon killall && bd daemon --start' to restart daemons\n")
}
return nil
}
func outputStatusJSON(status TownStatus) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(status)
}
func outputStatusText(status TownStatus) error {
// Header
fmt.Printf("%s %s\n", style.Bold.Render("Town:"), status.Name)
fmt.Printf("%s\n\n", style.Dim.Render(status.Location))
// Overseer info
if status.Overseer != nil {
overseerDisplay := status.Overseer.Name
if status.Overseer.Email != "" {
overseerDisplay = fmt.Sprintf("%s <%s>", status.Overseer.Name, status.Overseer.Email)
} else if status.Overseer.Username != "" && status.Overseer.Username != status.Overseer.Name {
overseerDisplay = fmt.Sprintf("%s (@%s)", status.Overseer.Name, status.Overseer.Username)
}
fmt.Printf("👤 %s %s\n", style.Bold.Render("Overseer:"), overseerDisplay)
if status.Overseer.UnreadMail > 0 {
fmt.Printf(" 📬 %d unread\n", status.Overseer.UnreadMail)
}
fmt.Println()
}
// Role icons - uses centralized emojis from constants package
roleIcons := map[string]string{
constants.RoleMayor: constants.EmojiMayor,
constants.RoleDeacon: constants.EmojiDeacon,
constants.RoleWitness: constants.EmojiWitness,
constants.RoleRefinery: constants.EmojiRefinery,
constants.RoleCrew: constants.EmojiCrew,
constants.RolePolecat: constants.EmojiPolecat,
// Legacy names for backwards compatibility
"coordinator": constants.EmojiMayor,
"health-check": constants.EmojiDeacon,
}
// Global Agents (Mayor, Deacon)
for _, agent := range status.Agents {
icon := roleIcons[agent.Role]
if icon == "" {
icon = roleIcons[agent.Name]
}
if statusVerbose {
fmt.Printf("%s %s\n", icon, style.Bold.Render(capitalizeFirst(agent.Name)))
renderAgentDetails(agent, " ", nil, status.Location)
fmt.Println()
} else {
// Compact: icon + name on one line
renderAgentCompact(agent, icon+" ", nil, status.Location)
}
}
if !statusVerbose && len(status.Agents) > 0 {
fmt.Println()
}
if len(status.Rigs) == 0 {
fmt.Printf("%s\n", style.Dim.Render("No rigs registered. Use 'gt rig add' to add one."))
return nil
}
// Rigs
for _, r := range status.Rigs {
// Rig header with separator
fmt.Printf("─── %s ───────────────────────────────────────────\n\n", style.Bold.Render(r.Name+"/"))
// Group agents by role
var witnesses, refineries, crews, polecats []AgentRuntime
for _, agent := range r.Agents {
switch agent.Role {
case "witness":
witnesses = append(witnesses, agent)
case "refinery":
refineries = append(refineries, agent)
case "crew":
crews = append(crews, agent)
case "polecat":
polecats = append(polecats, agent)
}
}
// Witness
if len(witnesses) > 0 {
if statusVerbose {
fmt.Printf("%s %s\n", roleIcons["witness"], style.Bold.Render("Witness"))
for _, agent := range witnesses {
renderAgentDetails(agent, " ", r.Hooks, status.Location)
}
fmt.Println()
} else {
for _, agent := range witnesses {
renderAgentCompact(agent, roleIcons["witness"]+" ", r.Hooks, status.Location)
}
}
}
// Refinery
if len(refineries) > 0 {
if statusVerbose {
fmt.Printf("%s %s\n", roleIcons["refinery"], style.Bold.Render("Refinery"))
for _, agent := range refineries {
renderAgentDetails(agent, " ", r.Hooks, status.Location)
}
// MQ summary (shown under refinery)
if r.MQ != nil {
mqStr := formatMQSummary(r.MQ)
if mqStr != "" {
fmt.Printf(" MQ: %s\n", mqStr)
}
}
fmt.Println()
} else {
for _, agent := range refineries {
// Compact: include MQ on same line if present
mqSuffix := ""
if r.MQ != nil {
mqStr := formatMQSummaryCompact(r.MQ)
if mqStr != "" {
mqSuffix = " " + mqStr
}
}
renderAgentCompactWithSuffix(agent, roleIcons["refinery"]+" ", r.Hooks, status.Location, mqSuffix)
}
}
}
// Crew
if len(crews) > 0 {
if statusVerbose {
fmt.Printf("%s %s (%d)\n", roleIcons["crew"], style.Bold.Render("Crew"), len(crews))
for _, agent := range crews {
renderAgentDetails(agent, " ", r.Hooks, status.Location)
}
fmt.Println()
} else {
fmt.Printf("%s %s (%d)\n", roleIcons["crew"], style.Bold.Render("Crew"), len(crews))
for _, agent := range crews {
renderAgentCompact(agent, " ", r.Hooks, status.Location)
}
}
}
// Polecats
if len(polecats) > 0 {
if statusVerbose {
fmt.Printf("%s %s (%d)\n", roleIcons["polecat"], style.Bold.Render("Polecats"), len(polecats))
for _, agent := range polecats {
renderAgentDetails(agent, " ", r.Hooks, status.Location)
}
fmt.Println()
} else {
fmt.Printf("%s %s (%d)\n", roleIcons["polecat"], style.Bold.Render("Polecats"), len(polecats))
for _, agent := range polecats {
renderAgentCompact(agent, " ", r.Hooks, status.Location)
}
}
}
// No agents
if len(witnesses) == 0 && len(refineries) == 0 && len(crews) == 0 && len(polecats) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no agents)"))
}
fmt.Println()
}
return nil
}
// renderAgentDetails renders full agent bead details
func renderAgentDetails(agent AgentRuntime, indent string, hooks []AgentHookInfo, townRoot string) { //nolint:unparam // indent kept for future customization
// Line 1: Agent bead ID + status
// Per gt-zecmc: derive status from tmux (observable reality), not bead state.
// "Discover, don't track" - agent liveness is observable from tmux session.
sessionExists := agent.Running
var statusStr string
var stateInfo string
if sessionExists {
statusStr = style.Success.Render("running")
} else {
statusStr = style.Error.Render("stopped")
}
// Show non-observable states that represent intentional agent decisions.
// These can't be discovered from tmux and are legitimately recorded in beads.
beadState := agent.State
switch beadState {
case "stuck":
// Agent escalated - needs help
stateInfo = style.Warning.Render(" [stuck]")
case "awaiting-gate":
// Agent waiting for external trigger (phase gate)
stateInfo = style.Dim.Render(" [awaiting-gate]")
case "muted", "paused", "degraded":
// Other intentional non-observable states
stateInfo = style.Dim.Render(fmt.Sprintf(" [%s]", beadState))
// Ignore observable states: "running", "idle", "dead", "done", "stopped", ""
// These should be derived from tmux, not bead.
}
// Build agent bead ID using canonical naming: prefix-rig-role-name
agentBeadID := "gt-" + agent.Name
if agent.Address != "" && agent.Address != agent.Name {
// Use address for full path agents like gastown/crew/joe → gt-gastown-crew-joe
addr := strings.TrimSuffix(agent.Address, "/") // Remove trailing slash for global agents
parts := strings.Split(addr, "/")
if len(parts) == 1 {
// Global agent: mayor/, deacon/ → hq-mayor, hq-deacon
agentBeadID = beads.AgentBeadIDWithPrefix(beads.TownBeadsPrefix, "", parts[0], "")
} else if len(parts) >= 2 {
rig := parts[0]
prefix := beads.GetPrefixForRig(townRoot, rig)
if parts[1] == "crew" && len(parts) >= 3 {
agentBeadID = beads.CrewBeadIDWithPrefix(prefix, rig, parts[2])
} else if parts[1] == "witness" {
agentBeadID = beads.WitnessBeadIDWithPrefix(prefix, rig)
} else if parts[1] == "refinery" {
agentBeadID = beads.RefineryBeadIDWithPrefix(prefix, rig)
} else if len(parts) == 2 {
// polecat: rig/name
agentBeadID = beads.PolecatBeadIDWithPrefix(prefix, rig, parts[1])
}
}
}
fmt.Printf("%s%s %s%s\n", indent, style.Dim.Render(agentBeadID), statusStr, stateInfo)
// Line 2: Hook bead (pinned work)
hookStr := style.Dim.Render("(none)")
hookBead := agent.HookBead
hookTitle := agent.WorkTitle
// Fall back to hooks array if agent bead doesn't have hook info
if hookBead == "" && hooks != nil {
for _, h := range hooks {
if h.Agent == agent.Address && h.HasWork {
hookBead = h.Molecule
hookTitle = h.Title
break
}
}
}
if hookBead != "" {
if hookTitle != "" {
hookStr = fmt.Sprintf("%s → %s", hookBead, truncateWithEllipsis(hookTitle, 40))
} else {
hookStr = hookBead
}
} else if hookTitle != "" {
// Has title but no molecule ID
hookStr = truncateWithEllipsis(hookTitle, 50)
}
fmt.Printf("%s hook: %s\n", indent, hookStr)
// Line 3: Mail (if any unread)
if agent.UnreadMail > 0 {
mailStr := fmt.Sprintf("📬 %d unread", agent.UnreadMail)
if agent.FirstSubject != "" {
mailStr = fmt.Sprintf("📬 %d unread → %s", agent.UnreadMail, truncateWithEllipsis(agent.FirstSubject, 35))
}
fmt.Printf("%s mail: %s\n", indent, mailStr)
}
}
// formatMQSummary formats the MQ status for verbose display
func formatMQSummary(mq *MQSummary) string {
if mq == nil {
return ""
}
mqParts := []string{}
if mq.Pending > 0 {
mqParts = append(mqParts, fmt.Sprintf("%d pending", mq.Pending))
}
if mq.InFlight > 0 {
mqParts = append(mqParts, style.Warning.Render(fmt.Sprintf("%d in-flight", mq.InFlight)))
}
if mq.Blocked > 0 {
mqParts = append(mqParts, style.Dim.Render(fmt.Sprintf("%d blocked", mq.Blocked)))
}
if len(mqParts) == 0 {
return ""
}
// Add state indicator
stateIcon := "○" // idle
switch mq.State {
case "processing":
stateIcon = style.Success.Render("●")
case "blocked":
stateIcon = style.Error.Render("○")
}
// Add health warning if stale
healthSuffix := ""
if mq.Health == "stale" {
healthSuffix = style.Error.Render(" [stale]")
}
return fmt.Sprintf("%s %s%s", stateIcon, strings.Join(mqParts, ", "), healthSuffix)
}
// formatMQSummaryCompact formats MQ status for compact single-line display
func formatMQSummaryCompact(mq *MQSummary) string {
if mq == nil {
return ""
}
// Very compact: "MQ:12" or "MQ:12 [stale]"
total := mq.Pending + mq.InFlight + mq.Blocked
if total == 0 {
return ""
}
healthSuffix := ""
if mq.Health == "stale" {
healthSuffix = style.Error.Render("[stale]")
}
return fmt.Sprintf("MQ:%d%s", total, healthSuffix)
}
// renderAgentCompactWithSuffix renders a single-line agent status with an extra suffix
func renderAgentCompactWithSuffix(agent AgentRuntime, indent string, hooks []AgentHookInfo, townRoot string, suffix string) {
// Build status indicator (gt-zecmc: use tmux state, not bead state)
statusIndicator := buildStatusIndicator(agent)
// Get hook info
hookBead := agent.HookBead
hookTitle := agent.WorkTitle
if hookBead == "" && hooks != nil {
for _, h := range hooks {
if h.Agent == agent.Address && h.HasWork {
hookBead = h.Molecule
hookTitle = h.Title
break
}
}
}
// Build hook suffix
hookSuffix := ""
if hookBead != "" {
if hookTitle != "" {
hookSuffix = style.Dim.Render(" → ") + truncateWithEllipsis(hookTitle, 30)
} else {
hookSuffix = style.Dim.Render(" → ") + hookBead
}
} else if hookTitle != "" {
hookSuffix = style.Dim.Render(" → ") + truncateWithEllipsis(hookTitle, 30)
}
// Mail indicator
mailSuffix := ""
if agent.UnreadMail > 0 {
mailSuffix = fmt.Sprintf(" 📬%d", agent.UnreadMail)
}
// Print single line: name + status + hook + mail + suffix
fmt.Printf("%s%-12s %s%s%s%s\n", indent, agent.Name, statusIndicator, hookSuffix, mailSuffix, suffix)
}
// renderAgentCompact renders a single-line agent status
func renderAgentCompact(agent AgentRuntime, indent string, hooks []AgentHookInfo, townRoot string) {
// Build status indicator (gt-zecmc: use tmux state, not bead state)
statusIndicator := buildStatusIndicator(agent)
// Get hook info
hookBead := agent.HookBead
hookTitle := agent.WorkTitle
if hookBead == "" && hooks != nil {
for _, h := range hooks {
if h.Agent == agent.Address && h.HasWork {
hookBead = h.Molecule
hookTitle = h.Title
break
}
}
}
// Build hook suffix
hookSuffix := ""
if hookBead != "" {
if hookTitle != "" {
hookSuffix = style.Dim.Render(" → ") + truncateWithEllipsis(hookTitle, 30)
} else {
hookSuffix = style.Dim.Render(" → ") + hookBead
}
} else if hookTitle != "" {
hookSuffix = style.Dim.Render(" → ") + truncateWithEllipsis(hookTitle, 30)
}
// Mail indicator
mailSuffix := ""
if agent.UnreadMail > 0 {
mailSuffix = fmt.Sprintf(" 📬%d", agent.UnreadMail)
}
// Print single line: name + status + hook + mail
fmt.Printf("%s%-12s %s%s%s\n", indent, agent.Name, statusIndicator, hookSuffix, mailSuffix)
}
// buildStatusIndicator creates the visual status indicator for an agent.
// Per gt-zecmc: uses tmux state (observable reality), not bead state.
// Non-observable states (stuck, awaiting-gate, muted, etc.) are shown as suffixes.
func buildStatusIndicator(agent AgentRuntime) string {
sessionExists := agent.Running
// Base indicator from tmux state
var indicator string
if sessionExists {
indicator = style.Success.Render("●")
} else {
indicator = style.Error.Render("○")
}
// Add non-observable state suffix if present
beadState := agent.State
switch beadState {
case "stuck":
indicator += style.Warning.Render(" stuck")
case "awaiting-gate":
indicator += style.Dim.Render(" gate")
case "muted", "paused", "degraded":
indicator += style.Dim.Render(" " + beadState)
// Ignore observable states: running, idle, dead, done, stopped, ""
}
return indicator
}
// formatHookInfo formats the hook bead and title for display
func formatHookInfo(hookBead, title string, maxLen int) string {
if hookBead == "" {
return ""
}
if title == "" {
return fmt.Sprintf(" → %s", hookBead)
}
title = truncateWithEllipsis(title, maxLen)
return fmt.Sprintf(" → %s", title)
}
// truncateWithEllipsis shortens a string to maxLen, adding "..." if truncated
func truncateWithEllipsis(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen < 4 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// capitalizeFirst capitalizes the first letter of a string
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return string(s[0]-32) + s[1:]
}
// discoverRigHooks finds all hook attachments for agents in a rig.
// It scans polecats, crew workers, witness, and refinery for handoff beads.
func discoverRigHooks(r *rig.Rig, crews []string) []AgentHookInfo {
var hooks []AgentHookInfo
// Create beads instance for the rig
b := beads.New(r.Path)
// Check polecats
for _, name := range r.Polecats {
hook := getAgentHook(b, name, r.Name+"/"+name, "polecat")
hooks = append(hooks, hook)
}
// Check crew workers
for _, name := range crews {
hook := getAgentHook(b, name, r.Name+"/crew/"+name, "crew")
hooks = append(hooks, hook)
}
// Check witness
if r.HasWitness {
hook := getAgentHook(b, "witness", r.Name+"/witness", "witness")
hooks = append(hooks, hook)
}
// Check refinery
if r.HasRefinery {
hook := getAgentHook(b, "refinery", r.Name+"/refinery", "refinery")
hooks = append(hooks, hook)
}
return hooks
}
// discoverGlobalAgents checks runtime state for town-level agents (Mayor, Deacon).
// Uses parallel fetching for performance. If skipMail is true, mail lookups are skipped.
// allSessions is a preloaded map of tmux sessions for O(1) lookup.
// allAgentBeads is a preloaded map of agent beads for O(1) lookup.
// allHookBeads is a preloaded map of hook beads for O(1) lookup.
func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]*beads.Issue, allHookBeads map[string]*beads.Issue, mailRouter *mail.Router, skipMail bool) []AgentRuntime {
// Get session names dynamically
mayorSession := getMayorSessionName()
deaconSession := getDeaconSessionName()
// Define agents to discover
// Note: Mayor and Deacon are town-level agents with hq- prefix bead IDs
agentDefs := []struct {
name string
address string
session string
role string
beadID string
}{
{"mayor", "mayor/", mayorSession, "coordinator", beads.MayorBeadIDTown()},
{"deacon", "deacon/", deaconSession, "health-check", beads.DeaconBeadIDTown()},
}
agents := make([]AgentRuntime, len(agentDefs))
var wg sync.WaitGroup
for i, def := range agentDefs {
wg.Add(1)
go func(idx int, d struct {
name string
address string
session string
role string
beadID string
}) {
defer wg.Done()
agent := AgentRuntime{
Name: d.name,
Address: d.address,
Session: d.session,
Role: d.role,
}
// Check tmux session from preloaded map (O(1))
agent.Running = allSessions[d.session]
// Look up agent bead from preloaded map (O(1))
if issue, ok := allAgentBeads[d.beadID]; ok {
// Prefer SQLite columns over description parsing
// HookBead column is authoritative (cleared by unsling)
agent.HookBead = issue.HookBead
agent.State = issue.AgentState
if agent.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
}
// Fallback to description for legacy beads without SQLite columns
if agent.State == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.State = fields.AgentState
}
}
}
// Get mail info (skip if --fast)
if !skipMail {
populateMailInfo(&agent, mailRouter)
}
agents[idx] = agent
}(i, def)
}
wg.Wait()
return agents
}
// populateMailInfo fetches unread mail count and first subject for an agent
func populateMailInfo(agent *AgentRuntime, router *mail.Router) {
if router == nil {
return
}
mailbox, err := router.GetMailbox(agent.Address)
if err != nil {
return
}
_, unread, _ := mailbox.Count()
agent.UnreadMail = unread
if unread > 0 {
if messages, err := mailbox.ListUnread(); err == nil && len(messages) > 0 {
agent.FirstSubject = messages[0].Subject
}
}
}
// agentDef defines an agent to discover
type agentDef struct {
name string
address string
session string
role string
beadID string
}
// discoverRigAgents checks runtime state for all agents in a rig.
// Uses parallel fetching for performance. If skipMail is true, mail lookups are skipped.
// allSessions is a preloaded map of tmux sessions for O(1) lookup.
// allAgentBeads is a preloaded map of agent beads for O(1) lookup.
// allHookBeads is a preloaded map of hook beads for O(1) lookup.
func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, allAgentBeads map[string]*beads.Issue, allHookBeads map[string]*beads.Issue, mailRouter *mail.Router, skipMail bool) []AgentRuntime {
// Build list of all agents to discover
var defs []agentDef
townRoot := filepath.Dir(r.Path)
prefix := beads.GetPrefixForRig(townRoot, r.Name)
// Witness
if r.HasWitness {
defs = append(defs, agentDef{
name: "witness",
address: r.Name + "/witness",
session: witnessSessionName(r.Name),
role: "witness",
beadID: beads.WitnessBeadIDWithPrefix(prefix, r.Name),
})
}
// Refinery
if r.HasRefinery {
defs = append(defs, agentDef{
name: "refinery",
address: r.Name + "/refinery",
session: fmt.Sprintf("gt-%s-refinery", r.Name),
role: "refinery",
beadID: beads.RefineryBeadIDWithPrefix(prefix, r.Name),
})
}
// Polecats
for _, name := range r.Polecats {
defs = append(defs, agentDef{
name: name,
address: r.Name + "/" + name,
session: fmt.Sprintf("gt-%s-%s", r.Name, name),
role: "polecat",
beadID: beads.PolecatBeadIDWithPrefix(prefix, r.Name, name),
})
}
// Crew
for _, name := range crews {
defs = append(defs, agentDef{
name: name,
address: r.Name + "/crew/" + name,
session: crewSessionName(r.Name, name),
role: "crew",
beadID: beads.CrewBeadIDWithPrefix(prefix, r.Name, name),
})
}
if len(defs) == 0 {
return nil
}
// Fetch all agents in parallel
agents := make([]AgentRuntime, len(defs))
var wg sync.WaitGroup
for i, def := range defs {
wg.Add(1)
go func(idx int, d agentDef) {
defer wg.Done()
agent := AgentRuntime{
Name: d.name,
Address: d.address,
Session: d.session,
Role: d.role,
}
// Check tmux session from preloaded map (O(1))
agent.Running = allSessions[d.session]
// Look up agent bead from preloaded map (O(1))
if issue, ok := allAgentBeads[d.beadID]; ok {
// Prefer SQLite columns over description parsing
// HookBead column is authoritative (cleared by unsling)
agent.HookBead = issue.HookBead
agent.State = issue.AgentState
if agent.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
}
// Fallback to description for legacy beads without SQLite columns
if agent.State == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.State = fields.AgentState
}
}
}
// Get mail info (skip if --fast)
if !skipMail {
populateMailInfo(&agent, mailRouter)
}
agents[idx] = agent
}(i, def)
}
wg.Wait()
return agents
}
// getMQSummary queries beads for merge-request issues and returns a summary.
// Returns nil if the rig has no refinery or no MQ issues.
func getMQSummary(r *rig.Rig) *MQSummary {
if !r.HasRefinery {
return nil
}
// Create beads instance for the rig
b := beads.New(r.BeadsPath())
// Query for all open merge-request type issues
opts := beads.ListOptions{
Type: "merge-request",
Status: "open",
Priority: -1, // No priority filter
}
openMRs, err := b.List(opts)
if err != nil {
return nil
}
// Query for in-progress merge-requests
opts.Status = "in_progress"
inProgressMRs, err := b.List(opts)
if err != nil {
return nil
}
// Count pending (open with no blockers) vs blocked
pending := 0
blocked := 0
for _, mr := range openMRs {
if len(mr.BlockedBy) > 0 || mr.BlockedByCount > 0 {
blocked++
} else {
pending++
}
}
// Determine queue state
state := "idle"
if len(inProgressMRs) > 0 {
state = "processing"
} else if pending > 0 {
state = "idle" // Has work but not processing yet
} else if blocked > 0 {
state = "blocked" // Only blocked items, nothing processable
}
// Determine queue health
health := "empty"
total := pending + len(inProgressMRs) + blocked
if total > 0 {
health = "healthy"
// Check for potential issues
if pending > 10 && len(inProgressMRs) == 0 {
// Large queue but nothing processing - may be stuck
health = "stale"
}
}
// Only return summary if there's something to show
if pending == 0 && len(inProgressMRs) == 0 && blocked == 0 {
return nil
}
return &MQSummary{
Pending: pending,
InFlight: len(inProgressMRs),
Blocked: blocked,
State: state,
Health: health,
}
}
// getAgentHook retrieves hook status for a specific agent.
func getAgentHook(b *beads.Beads, role, agentAddress, roleType string) AgentHookInfo {
hook := AgentHookInfo{
Agent: agentAddress,
Role: roleType,
}
// Find handoff bead for this role
handoff, err := b.FindHandoffBead(role)
if err != nil || handoff == nil {
return hook
}
// Check for attachment
attachment := beads.ParseAttachmentFields(handoff)
if attachment != nil && attachment.AttachedMolecule != "" {
hook.HasWork = true
hook.Molecule = attachment.AttachedMolecule
hook.Title = handoff.Title
} else if handoff.Description != "" {
// Has content but no molecule - still has work
hook.HasWork = true
hook.Title = handoff.Title
}
return hook
}