TUI: folder/started/closed columns, heartbeat staleness, hide-closed (x), hide-sub (z), sort (s), detail pane, hunk in agent cwd, tuxedo project todo, zellij/tmux attach, fix remote JSON parsing

This commit is contained in:
2026-08-04 11:04:41 +10:00
parent 112698cada
commit f0571465b2
3 changed files with 666 additions and 173 deletions

View File

@@ -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"`
}{

604
main.go
View File

@@ -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 <cwd>/.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:

227
state.go
View File

@@ -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)
}