diff --git a/config.go b/config.go index 9e93d82..8e0718b 100644 --- a/config.go +++ b/config.go @@ -11,7 +11,11 @@ import ( // Config holds the dashboard configuration type Config struct { PollInterval int `yaml:"poll_interval"` // in seconds - Local struct { + // StaleAfter is how many seconds without a heartbeat before a session + // is considered closed (terminal killed without graceful shutdown). + StaleAfter int `yaml:"stale_after"` + HideClosed bool `yaml:"hide_closed"` // start with closed sessions hidden + Local struct { Path string `yaml:"path"` } `yaml:"local"` Remote []RemoteConfig `yaml:"remote"` @@ -29,6 +33,8 @@ func defaultConfig() Config { home, _ := os.UserHomeDir() return Config{ PollInterval: 2, + StaleAfter: 45, + HideClosed: false, Local: struct { Path string `yaml:"path"` }{ diff --git a/main.go b/main.go index 4676b26..0d69d91 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "sort" "strings" "time" @@ -22,10 +24,20 @@ var ( Padding(0, 1) statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running") - statusCompleted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("✓ done") - statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).SetString("○ idle") + statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle") statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked") statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error") + statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed") + statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost") + subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇") + + detailKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00d4ff")).Bold(true) + detailLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#aaaaaa")) + detailValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#dddddd")) + detailBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1) infoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#aaaaaa")). @@ -36,29 +48,48 @@ var ( Padding(0, 1) ) +// Sort modes +const ( + sortSmart = iota // live first, running first, then last activity + sortActivity // most recent activity first + sortStarted // most recently started first + sortFolder // folder name A-Z +) + +var sortNames = map[int]string{ + sortSmart: "smart", + sortActivity: "activity", + sortStarted: "started", + sortFolder: "folder", +} + // Model type model struct { - table table.Model - help help.Model - agents []AgentState - config Config - lastPoll time.Time - err error - ready bool - windowWidth int + table table.Model + help help.Model + agents []AgentState // filtered + sorted view + config Config + hideClosed bool + hideSubagents bool + sortMode int + lastPoll time.Time + err error + ready bool + windowWidth int } type pollMsg struct{} func initialModel(cfg Config) model { columns := []table.Column{ - {Title: "Machine", Width: 12}, + {Title: "Type", Width: 5}, {Title: "Session", Width: 16}, + {Title: "Folder", Width: 16}, {Title: "Status", Width: 10}, - {Title: "Model", Width: 18}, - {Title: "Duration", Width: 10}, - {Title: "Tools", Width: 6}, - {Title: "Task", Width: 30}, + {Title: "Model", Width: 14}, + {Title: "Started", Width: 10}, + {Title: "Tools", Width: 5}, + {Title: "Task", Width: 24}, } t := table.New( @@ -80,13 +111,24 @@ func initialModel(cfg Config) model { t.SetStyles(s) return model{ - table: t, - help: help.New(), - agents: []AgentState{}, - config: cfg, + table: t, + help: help.New(), + agents: []AgentState{}, + config: cfg, + hideClosed: cfg.HideClosed, + hideSubagents: false, + sortMode: sortSmart, } } +func (m model) staleAfter() time.Duration { + s := m.config.StaleAfter + if s <= 0 { + s = 45 + } + return time.Duration(s) * time.Second +} + func (m model) fetchAllAgents() ([]AgentState, error) { var all []AgentState @@ -135,6 +177,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, openHunk(m) case key.Matches(msg, keys.Tuxedo): return m, openTuxedo(m) + case key.Matches(msg, keys.Sort): + m.sortMode = (m.sortMode + 1) % len(sortNames) + m.updateTable() + return m, nil + case key.Matches(msg, keys.HideClosed): + m.hideClosed = !m.hideClosed + m.updateTable() + return m, nil + case key.Matches(msg, keys.HideSubagents): + m.hideSubagents = !m.hideSubagents + m.updateTable() + return m, nil } case pollMsg: @@ -167,13 +221,28 @@ func (m model) View() string { var b strings.Builder // Header - summary := fmt.Sprintf("%d agent(s)", len(m.agents)) + live, closed := 0, 0 + for _, a := range m.agents { + if a.IsClosed(m.staleAfter()) { + closed++ + } else { + live++ + } + } + summary := fmt.Sprintf("%d agent(s) · %d live · %d closed", len(m.agents), live, closed) + if m.hideClosed { + summary += " │ closed hidden" + } + if m.hideSubagents { + summary += " │ subagents hidden" + } if len(m.config.Remote) > 0 { summary += fmt.Sprintf(" │ %d remote(s)", len(m.config.Remote)) } b.WriteString(headerStyle.Render(fmt.Sprintf( - "╡ Pi Dashboard │ %s │ %s ╞", + "╡ Pi Dashboard │ %s │ sort: %s │ %s ╞", summary, + sortNames[m.sortMode], time.Now().Format("15:04:05"), ))) b.WriteString("\n\n") @@ -189,34 +258,154 @@ func (m model) View() string { b.WriteString(m.table.View()) b.WriteString("\n") + // Detail pane for the selected row + if d := m.detailView(); d != "" { + b.WriteString(detailBoxStyle.Render(d)) + b.WriteString("\n") + } + // Keybindings help b.WriteString(footerStyle.Render( - "↑↓ navigate · Enter attach · d hunk · Tab tuxedo · r refresh · q quit", + "↑↓ select · Enter attach · d hunk · Tab tuxedo · s sort · x hide closed · z hide sub · r refresh · q quit", )) return b.String() } +func (m model) detailView() string { + if len(m.agents) == 0 { + return "" + } + row := m.table.Cursor() + if row < 0 || row >= len(m.agents) { + return "" + } + a := m.agents[row] + + stale := m.staleAfter() + term := a.TerminalType + if term == "" { + term = a.ConnectionType + } + if term == "" { + term = "direct" + } + var termExtra string + switch { + case a.ZellijSession != "" && a.TmuxSession != "": + termExtra = fmt.Sprintf("zellij:%s tmux:%s", a.ZellijSession, a.TmuxSession) + case a.ZellijSession != "": + termExtra = fmt.Sprintf("zellij:%s", a.ZellijSession) + case a.TmuxSession != "": + termExtra = fmt.Sprintf("tmux:%s", a.TmuxSession) + } + if termExtra != "" { + term += " (" + termExtra + ")" + } + if a.PID > 0 { + term += fmt.Sprintf(" pid:%d", a.PID) + } + + model := a.Model + if model == "" { + model = "unknown" + } + + closedInfo := "—" + if a.IsClosed(stale) { + if a.ClosedAt != nil && *a.ClosedAt != "" { + closedInfo = fmtTime(a.ClosedAt) + " (graceful)" + } else { + closedInfo = fmtTime(a.LastSeenAt) + " (last seen)" + } + } + + var lines []string + lines = append(lines, fmt.Sprintf("%s %s %s %s", + detailKeyStyle.Render(a.Session), + detailLabelStyle.Render(a.Machine), + a.StatusStr(stale), + detailLabelStyle.Render(model), + )) + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Folder:"), detailValStyle.Render(a.Cwd))) + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Terminal:"), detailValStyle.Render(term))) + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Started:"), detailValStyle.Render(fmtTime(a.SessionStartedAt)))) + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Closed:"), detailValStyle.Render(closedInfo))) + lines = append(lines, fmt.Sprintf("%s %s %s %s", + detailLabelStyle.Render("Tools:"), detailValStyle.Render(fmt.Sprintf("%d", a.ToolCalls)), + detailLabelStyle.Render("Duration:"), detailValStyle.Render(a.DurationStr()))) + if a.LastTool != nil && a.LastToolAt != nil { + lines = append(lines, fmt.Sprintf("%s %s %s", + detailLabelStyle.Render("Last tool:"), detailValStyle.Render(*a.LastTool), + detailLabelStyle.Render("at "+fmtTime(a.LastToolAt)))) + } + if a.CurrentTask != "" { + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Task:"), detailValStyle.Render(a.CurrentTask))) + } + if a.Blocked { + lines = append(lines, fmt.Sprintf("%s %s", + detailLabelStyle.Render("Blocked:"), detailValStyle.Render(strval(a.BlockedPrompt)))) + } + return strings.Join(lines, "\n") +} + +func strval(s *string) string { + if s == nil { + return "" + } + return *s +} + func (m *model) updateTable() { - var rows []table.Row + // Filter + var view []AgentState for _, a := range m.agents { - status := statusStr(a) + if m.hideClosed && a.IsClosed(m.staleAfter()) { + continue + } + if m.hideSubagents && a.IsSubagent { + continue + } + view = append(view, a) + } + + // Sort + sortAgents(view, m.sortMode, m.staleAfter()) + + // Render rows + var rows []table.Row + for _, a := range view { + session := a.Session + if a.IsSubagent { + session = "◇ " + session + } task := strings.ReplaceAll(a.CurrentTask, "\n", " ") - if len(task) > 30 { - task = task[:28] + ".." + if len(task) > 24 { + task = task[:22] + ".." } rows = append(rows, table.Row{ - a.MachineShort(), - a.Session, - status, + termSymbol(a.TerminalType, a.ConnectionType), + trunc(session, 16), + trunc(a.Folder(), 16), + a.StatusStr(m.staleAfter()), a.ShortModel(), - a.DurationStr(), + fmtTime(a.SessionStartedAt), fmt.Sprintf("%d", a.ToolCalls), task, }) } m.table.SetRows(rows) + // Keep cursor in range + if m.table.Cursor() > len(rows)-1 && len(rows) > 0 { + m.table.SetCursor(len(rows) - 1) + } + // Auto-height h := len(rows) + 2 if h < 5 { @@ -228,6 +417,74 @@ func (m *model) updateTable() { m.table.SetHeight(h) } +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 2 { + return s[:n] + } + return s[:n-2] + ".." +} + +func termSymbol(termType, connType string) string { + t := termType + if t == "" { + t = connType + } + switch { + case strings.Contains(t, "tmux") && strings.Contains(t, "zellij"): + return "ⓉⓏ" + case strings.Contains(t, "tmux"): + return "Ⓣ" + case strings.Contains(t, "zellij"): + return "Ⓩ" + default: + return "·" + } +} + +// statusRank orders live sessions: running < blocked < error < idle +func statusRank(a AgentState) int { + switch a.agentStatus() { + case "running": + return 0 + case "blocked": + return 1 + case "error": + return 2 + default: + return 3 + } +} + +func sortAgents(agents []AgentState, mode int, stale time.Duration) { + sort.SliceStable(agents, func(i, j int) bool { + a, b := agents[i], agents[j] + ac, bc := a.IsClosed(stale), b.IsClosed(stale) + switch mode { + case sortSmart: + if ac != bc { + return !ac // live sessions first + } + if !ac { + ra, rb := statusRank(a), statusRank(b) + if ra != rb { + return ra < rb + } + } + return a.lastActivity().After(b.lastActivity()) + case sortActivity: + return a.lastActivity().After(b.lastActivity()) + case sortStarted: + return a.sessionStart().After(b.sessionStart()) + case sortFolder: + return strings.ToLower(a.Folder()) < strings.ToLower(b.Folder()) + } + return false + }) +} + // ── Commands ───────────────────────────────────────────────────── func tick() tea.Cmd { @@ -244,6 +501,11 @@ func pollNow() tea.Cmd { type errMsg struct{ err error } +// attachToAgent attaches to the selected agent's terminal session: +// - tmux session → tmux attach (local) / ssh tmux attach (remote) +// - zellij session → zellij attach from outside, or +// `zellij action switch-session` when the dashboard itself runs inside zellij +// - direct → no attach target; show an error func attachToAgent(m model) tea.Cmd { if len(m.agents) == 0 { return nil @@ -253,74 +515,52 @@ func attachToAgent(m model) tea.Cmd { return nil } a := m.agents[row] - name := a.ConnectionName - if name == "" { - name = a.Session + + term := a.TerminalType + if term == "" { + term = a.ConnectionType + } + + var bin string + var args []string + + switch { + case strings.Contains(term, "tmux") && a.TmuxSession != "": + bin, args = "tmux", []string{"attach-session", "-t", a.TmuxSession} + case strings.Contains(term, "zellij") && a.ZellijSession != "": + bin = "zellij" + if os.Getenv("ZELLIJ_SESSION_NAME") != "" { + // Dashboard is running inside zellij — switch instead of attach + args = []string{"action", "switch-session", a.ZellijSession} + } else { + args = []string{"attach", a.ZellijSession} + } + default: + m.err = fmt.Errorf("no attach target for %s (terminal: %s)", a.Session, term) + return nil } if a.IsLocal() { return tea.ExecProcess( - exec.Command("tmux", "attach-session", "-t", name), + exec.Command(bin, args...), func(err error) tea.Msg { - if err != nil { return errMsg{err} } + if err != nil { + return errMsg{err} + } return nil }, ) } - // Remote agent: find SSH config - for _, r := range m.config.Remote { - // Match by hostname in the machine field - if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { - user := r.User - if user == "" { user = "sam" } - return tea.ExecProcess( - exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", user, r.Host), - "tmux", "attach-session", "-t", name), - func(err error) tea.Msg { - if err != nil { return errMsg{err} } - return nil - }, - ) - } - } - - // Fallback: show the machine name - m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) - return nil -} - -func openHunk(m model) tea.Cmd { - if len(m.agents) == 0 { + // Remote agent: run the attach command over SSH + sshHost, sshUser := findRemote(m, a) + if sshHost == "" { + m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) return nil } - row := m.table.Cursor() - if row < 0 || row >= len(m.agents) { - return nil - } - - // Try to find a worktree or repo path - // For now, just run hunk in the current directory - return tea.ExecProcess(exec.Command("hunk", "diff", "--watch"), func(err error) tea.Msg { - if err != nil { - return errMsg{err} - } - return nil - }) -} - -func openTuxedo(m model) tea.Cmd { - home, _ := os.UserHomeDir() - todoFile := home + "/.pi/agent/dashboard/tasks/todo.txt" - - // Ensure the file exists before opening - if _, err := os.Stat(todoFile); os.IsNotExist(err) { - // Create an empty todo.txt - os.WriteFile(todoFile, []byte(""), 0644) - } - + remoteCmd := strings.Join(append([]string{bin}, args...), " ") return tea.ExecProcess( - exec.Command("tuxedo", todoFile), + exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), func(err error) tea.Msg { if err != nil { return errMsg{err} @@ -330,39 +570,161 @@ func openTuxedo(m model) tea.Cmd { ) } +// openHunk reviews the selected agent's changes with Hunk, in the folder +// the agent was actually running in. +func openHunk(m model) tea.Cmd { + if len(m.agents) == 0 { + return nil + } + row := m.table.Cursor() + if row < 0 || row >= len(m.agents) { + return nil + } + a := m.agents[row] + + if a.Cwd == "" { + m.err = fmt.Errorf("no folder recorded for %s", a.Session) + return nil + } + + if a.IsLocal() { + cmd := exec.Command("hunk", "diff", "--watch") + cmd.Dir = a.Cwd + return tea.ExecProcess( + cmd, + func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return nil + }, + ) + } + + // Remote: run hunk over SSH in the agent's folder + sshHost, sshUser := findRemote(m, a) + if sshHost == "" { + m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) + return nil + } + remoteCmd := fmt.Sprintf("cd %s && hunk diff --watch", a.Cwd) + return tea.ExecProcess( + exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), + func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return nil + }, + ) +} + +// openTuxedo opens the selected agent's project task board (todo.txt), +// which lives at /.pi/dashboard/tasks/todo.txt. +func openTuxedo(m model) tea.Cmd { + home, _ := os.UserHomeDir() + globalTodo := home + "/.pi/agent/dashboard/tasks/todo.txt" + + var todoPath string + var agent AgentState + haveAgent := false + if len(m.agents) > 0 { + row := m.table.Cursor() + if row >= 0 && row < len(m.agents) { + agent = m.agents[row] + haveAgent = true + if agent.Cwd != "" { + todoPath = agent.Cwd + "/.pi/dashboard/tasks/todo.txt" + } + } + } + if todoPath == "" { + todoPath = globalTodo + } + + ensureTodo := func(path string) { + os.MkdirAll(filepath.Dir(path), 0755) + if _, err := os.Stat(path); os.IsNotExist(err) { + os.WriteFile(path, []byte(""), 0644) + } + } + + if !haveAgent || agent.IsLocal() { + ensureTodo(todoPath) + return tea.ExecProcess( + exec.Command("tuxedo", todoPath), + func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return nil + }, + ) + } + + // Remote: run tuxedo over SSH on the agent's project todo + sshHost, sshUser := findRemote(m, agent) + if sshHost == "" { + m.err = fmt.Errorf("no SSH config for remote machine: %s", agent.Machine) + return nil + } + remoteCmd := fmt.Sprintf("mkdir -p %s && touch %s && tuxedo %s", + filepath.Dir(todoPath), todoPath, todoPath) + return tea.ExecProcess( + exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), + func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return nil + }, + ) +} + +// findRemote returns the (host, user) for the SSH config matching a machine, +// or ("", "") if none matches. +func findRemote(m model, a AgentState) (string, string) { + for _, r := range m.config.Remote { + if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { + user := r.User + if user == "" { + user = "sam" + } + return r.Host, user + } + } + return "", "" +} + // ── Keybindings ────────────────────────────────────────────────── type keyMap struct { - Quit key.Binding - Refresh key.Binding - Attach key.Binding - Hunk key.Binding - Tuxedo key.Binding + Quit key.Binding + Refresh key.Binding + Attach key.Binding + Hunk key.Binding + Tuxedo key.Binding + Sort key.Binding + HideClosed key.Binding + HideSubagents key.Binding } var keys = keyMap{ - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), - Attach: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "attach to agent")), - Hunk: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "hunk diff")), - Tuxedo: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "tuxedo tasks")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Attach: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "attach to agent")), + Hunk: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "hunk diff in agent folder")), + Tuxedo: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "tuxedo project tasks")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")), + HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")), } // ── Helpers ────────────────────────────────────────────────────── func statusStr(a AgentState) string { - switch a.Status { - case "running": - return "● running" - case "completed": - return "✓ done" - case "error": - return "✕ error" - case "blocked": - return "⚠ blocked" - default: - return "○ idle" - } + // Uses a 45s stale window for the plain-text list mode + return a.StatusStr(45 * time.Second) } func printAgentList(agents []AgentState) { @@ -370,16 +732,23 @@ func printAgentList(agents []AgentState) { fmt.Println("No agents found in ~/.pi/agent/dashboard/") return } - fmt.Printf("%-18s %-14s %-20s %-10s %s\n", "Session", "Machine", "Model", "Status", "Task") - fmt.Println(strings.Repeat("-", 90)) + fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Task") + fmt.Println(strings.Repeat("-", 100)) for _, a := range agents { task := strings.ReplaceAll(a.CurrentTask, "\n", " ") if len(task) > 35 { task = task[:33] + ".." } - fmt.Printf("%-18s %-14s %-20s %-10s %s\n", - a.Session, a.MachineShort(), a.ShortModel(), statusStr(a), task) + fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", + termSymbol(a.TerminalType, a.ConnectionType), + trunc(a.Session, 18), + trunc(a.Folder(), 16), + statusStr(a), + fmtTime(a.SessionStartedAt), + task) } + fmt.Println(strings.Repeat("-", 100)) + fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct") } // ── Entry point ────────────────────────────────────────────────── @@ -408,14 +777,19 @@ Usage: Keybindings (TUI mode): ↑↓ Navigate agents - Enter Attach to agent (tmux attach) - d Open hunk diff - Tab Open tuxedo task list + Enter Attach to agent (tmux attach / zellij attach / switch) + d Review agent's changes with Hunk (in the agent's folder) + Tab Open agent's project task board in Tuxedo + s Cycle sort: smart > activity > started > folder + x Toggle hiding closed sessions + z Toggle hiding sub-agent sessions r Force refresh q Quit Config: ~/.config/pi-dashboard.yaml - poll_interval: 2 # seconds + poll_interval: 2 # seconds + stale_after: 45 # seconds without heartbeat => session considered closed + hide_closed: false # start with closed sessions hidden local: path: ~/.pi/agent/dashboard/ remote: diff --git a/state.go b/state.go index 280592a..8225c16 100644 --- a/state.go +++ b/state.go @@ -7,42 +7,132 @@ import ( "os/exec" "path/filepath" "strings" + "time" ) // AgentState matches the TypeScript interface from dashboard.ts type AgentState struct { - Session string `json:"session"` - Machine string `json:"machine"` - AgentType string `json:"agent_type"` - Model string `json:"model"` - Status string `json:"status"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - DurationSeconds int `json:"duration_seconds"` - ToolCalls int `json:"tool_calls"` - CurrentTask string `json:"current_task"` - LastTool *string `json:"last_tool"` - LastToolAt *string `json:"last_tool_at"` - ConnectionType string `json:"connection_type"` - ConnectionName string `json:"connection_name"` - Blocked bool `json:"blocked"` - BlockedPrompt *string `json:"blocked_prompt"` - CostEstimate float64 `json:"cost_estimate"` + Session string `json:"session"` + Machine string `json:"machine"` + Cwd string `json:"cwd"` + AgentType string `json:"agent_type"` + Model string `json:"model"` + AgentStatus string `json:"agent_status"` + Status string `json:"status"` // legacy alias of agent_status + SessionState string `json:"session_state"` + SessionStartedAt *string `json:"session_started_at"` + ClosedAt *string `json:"closed_at"` + LastSeenAt *string `json:"last_seen_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + DurationSeconds int `json:"duration_seconds"` + ToolCalls int `json:"tool_calls"` + CurrentTask string `json:"current_task"` + LastTool *string `json:"last_tool"` + LastToolAt *string `json:"last_tool_at"` + TerminalType string `json:"terminal_type"` + ZellijSession string `json:"zellij_session"` + TmuxSession string `json:"tmux_session"` + PID int `json:"pid"` + IsSubagent bool `json:"is_subagent"` + ConnectionType string `json:"connection_type"` + ConnectionName string `json:"connection_name"` + Blocked bool `json:"blocked"` + BlockedPrompt *string `json:"blocked_prompt"` + CostEstimate float64 `json:"cost_estimate"` } -func (a AgentState) StatusSymbol() string { - switch a.Status { - case "running": - return "●" - case "completed": - return "✓" - case "error": - return "✕" - case "blocked": - return "⚠" - default: - return "○" +// agentStatus returns the agent-turn status, falling back to the legacy +// "status" field for state files written by older extension versions. +func (a AgentState) agentStatus() string { + if a.AgentStatus != "" { + return a.AgentStatus } + return a.Status +} + +// IsClosed reports whether the session is no longer alive: +// - the extension marked it closed (session_state == "closed"), or +// - the heartbeat (last_seen_at) is older than staleAfter (killed +// without session_shutdown, e.g. closed terminal, kill -9), or +// - there is no heartbeat at all (pre-heartbeat state files) +func (a AgentState) IsClosed(staleAfter time.Duration) bool { + if a.SessionState == "closed" { + return true + } + ls := a.LastSeenAt + if ls == nil || *ls == "" { + return true + } + t, err := time.Parse(time.RFC3339, *ls) + if err != nil { + return true + } + return time.Since(t) > staleAfter +} + +// WasKilled is true when the session died without a graceful shutdown +// (closed_at never written by the extension). +func (a AgentState) WasKilled(staleAfter time.Duration) bool { + if a.SessionState == "closed" || a.ClosedAt != nil { + return false + } + return a.IsClosed(staleAfter) +} + +func (a AgentState) StatusStr(staleAfter time.Duration) string { + if a.IsClosed(staleAfter) { + if a.WasKilled(staleAfter) { + return "✗ lost" + } + return "✗ closed" + } + switch a.agentStatus() { + case "running": + return "● running" + case "blocked": + return "⚠ blocked" + case "error": + return "✕ error" + default: + return "○ idle" + } +} + +// Folder returns the basename of the working directory the session ran in. +func (a AgentState) Folder() string { + if a.Cwd == "" { + return a.Session + } + trimmed := strings.TrimRight(a.Cwd, "/") + base := filepath.Base(trimmed) + if base == "/" || base == "." || base == "" { + return a.Cwd + } + return base +} + +func (a AgentState) ShortModel() string { + parts := strings.Split(a.Model, "/") + if len(parts) == 2 { + s := parts[1] + if len(s) > 14 { + s = s[:12] + ".." + } + return s + } + if len(a.Model) > 14 { + return a.Model[:12] + ".." + } + return a.Model +} + +func (a AgentState) MachineShort() string { + s := a.Machine + if len(s) > 12 { + s = s[:10] + ".." + } + return s } func (a AgentState) DurationStr() string { @@ -59,27 +149,43 @@ func (a AgentState) DurationStr() string { return fmt.Sprintf("%dh%dm", d/3600, (d%3600)/60) } -func (a AgentState) ShortModel() string { - parts := strings.Split(a.Model, "/") - if len(parts) == 2 { - s := parts[1] - if len(s) > 16 { - s = s[:14] + ".." - } - return s +// fmtTime renders an RFC3339 timestamp compactly: HH:MM for today, +// MM-DD HH:MM otherwise. +func fmtTime(s *string) string { + if s == nil || *s == "" { + return "" } - if len(a.Model) > 16 { - return a.Model[:14] + ".." + t, err := time.Parse(time.RFC3339, *s) + if err != nil { + return "" } - return a.Model + now := time.Now() + if t.Year() == now.Year() && t.YearDay() == now.YearDay() { + return t.Format("15:04") + } + return t.Format("01-02 15:04") } -func (a AgentState) MachineShort() string { - s := a.Machine - if len(s) > 12 { - s = s[:10] + ".." +// lastActivity returns the most recent timestamp we know about for the session. +func (a AgentState) lastActivity() time.Time { + for _, s := range []*string{a.LastSeenAt, a.CompletedAt, a.StartedAt, a.SessionStartedAt} { + if s != nil { + if t, err := time.Parse(time.RFC3339, *s); err == nil { + return t + } + } } - return s + return time.Time{} +} + +// sessionStart returns when the pi session began. +func (a AgentState) sessionStart() time.Time { + if a.SessionStartedAt != nil { + if t, err := time.Parse(time.RFC3339, *a.SessionStartedAt); err == nil { + return t + } + } + return a.lastActivity() } // IsLocal returns true if this agent is on the same machine @@ -121,10 +227,22 @@ func readStateFile(path string) (AgentState, error) { if err := json.Unmarshal(data, &s); err != nil { return AgentState{}, err } + + // Legacy state files (pre-heartbeat extension) have no last_seen_at. + // The old extension rewrote the file on every event, so the file mtime + // is a good liveness proxy for them. + if s.LastSeenAt == nil || *s.LastSeenAt == "" { + if fi, err := os.Stat(path); err == nil { + t := fi.ModTime().UTC().Format(time.RFC3339) + s.LastSeenAt = &t + } + } return s, nil } -// readRemoteStates SSHes into a machine and reads state files +// readRemoteStates SSHes into a machine and reads its state files. +// State files are pretty-printed multi-line JSON, so `cat *.json` +// concatenates several objects — decode them as a stream. func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) { hostPort := cfg.Host if cfg.Port > 0 && cfg.Port != 22 { @@ -142,7 +260,7 @@ func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) { remotePath = filepath.Join(home, ".pi", "agent", "dashboard") } - // SSH in, list JSON files, cat each one + // SSH in, cat all JSON files cmd := exec.Command("ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", @@ -154,18 +272,13 @@ func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) { return nil, fmt.Errorf("ssh %s: %w", cfg.Host, err) } - // Multiple JSON objects concatenated — split by newline boundaries - // and parse each line individually - lines := strings.Split(strings.TrimSpace(string(output)), "\n") + // Multiple pretty-printed JSON objects concatenated — stream-decode var states []AgentState - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } + dec := json.NewDecoder(strings.NewReader(string(output))) + for { var s AgentState - if err := json.Unmarshal([]byte(line), &s); err != nil { - continue // skip malformed lines + if err := dec.Decode(&s); err != nil { + break } states = append(states, s) }