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:
227
state.go
227
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user