Pi Dashboard TUI viewer — Go + Bubble Tea. Reads pi agent state files, cross-machine SSH pull, Enter=tmux attach, d=Hunk diff, Tab=Tuxedo tasks
This commit is contained in:
173
state.go
Normal file
173
state.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
func (a AgentState) StatusSymbol() string {
|
||||
switch a.Status {
|
||||
case "running":
|
||||
return "●"
|
||||
case "completed":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✕"
|
||||
case "blocked":
|
||||
return "⚠"
|
||||
default:
|
||||
return "○"
|
||||
}
|
||||
}
|
||||
|
||||
func (a AgentState) DurationStr() string {
|
||||
d := a.DurationSeconds
|
||||
if d == 0 {
|
||||
return ""
|
||||
}
|
||||
if d < 60 {
|
||||
return fmt.Sprintf("%ds", d)
|
||||
}
|
||||
if d < 3600 {
|
||||
return fmt.Sprintf("%dm%ds", d/60, d%60)
|
||||
}
|
||||
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
|
||||
}
|
||||
if len(a.Model) > 16 {
|
||||
return a.Model[:14] + ".."
|
||||
}
|
||||
return a.Model
|
||||
}
|
||||
|
||||
func (a AgentState) MachineShort() string {
|
||||
s := a.Machine
|
||||
if len(s) > 12 {
|
||||
s = s[:10] + ".."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsLocal returns true if this agent is on the same machine
|
||||
func (a AgentState) IsLocal() bool {
|
||||
return a.Machine == localHostname
|
||||
}
|
||||
|
||||
var localHostname string
|
||||
|
||||
// readLocalStates reads all JSON files from the given directory
|
||||
func readLocalStates(dir string) ([]AgentState, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading dir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var states []AgentState
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
s, err := readStateFile(filepath.Join(dir, entry.Name()))
|
||||
if err == nil {
|
||||
states = append(states, s)
|
||||
}
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func readStateFile(path string) (AgentState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return AgentState{}, err
|
||||
}
|
||||
var s AgentState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return AgentState{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// readRemoteStates SSHes into a machine and reads state files
|
||||
func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) {
|
||||
hostPort := cfg.Host
|
||||
if cfg.Port > 0 && cfg.Port != 22 {
|
||||
hostPort = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
}
|
||||
|
||||
user := cfg.User
|
||||
if user == "" {
|
||||
user = "sam"
|
||||
}
|
||||
|
||||
remotePath := cfg.Path
|
||||
if remotePath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
remotePath = filepath.Join(home, ".pi", "agent", "dashboard")
|
||||
}
|
||||
|
||||
// SSH in, list JSON files, cat each one
|
||||
cmd := exec.Command("ssh",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "BatchMode=yes",
|
||||
fmt.Sprintf("%s@%s", user, hostPort),
|
||||
fmt.Sprintf("cat %s/*.json 2>/dev/null || true", remotePath),
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
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")
|
||||
var states []AgentState
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var s AgentState
|
||||
if err := json.Unmarshal([]byte(line), &s); err != nil {
|
||||
continue // skip malformed lines
|
||||
}
|
||||
states = append(states, s)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
Reference in New Issue
Block a user