fb76b53c17
El orquestador identificaba cada agente por el campo tmux_window (@N), pero el window_id de tmux cambia cuando un pane entra/sale de windows (el focus de la flota usa break-pane + join-pane, que recrean windows). El pane_id (%N) en cambio es estable durante toda la vida del pane: es el identificador correcto. - claude_fleet.go: nuevo campo ClaudeFleet.PaneID `json:"pane_id"`. Se mantiene TmuxWindow (lo necesita el focus internamente); esto AÑADE pane_id, no lo reemplaza. - resolve_pane_ids.go (+ .md, .go test): nueva función del registry ResolvePaneIDs(socket, pids) -> map[pid]pane_id. Lista los panes del socket (tmux -L <socket> list-panes -a) y para cada PID sube por el árbol de procesos (PPID en /proc) hasta dar con un pane_pid. Reutiliza runTmux y procPPID del paquete infra. Best-effort: tmux/socket caído o PID sin pane -> "" sin crash. Núcleo testeable con inyección de la salida tmux y del resolvedor de PPID. - list_claude_fleet.go: ListClaudeFleet() puebla PaneID resolviendo cada PID vivo contra $FLEET_SOCKET (default "fleet"). Solo la entrada pública lo hace; ListClaudeFleetFrom() queda intacta (cero coste tmux en tests y en el bucle de render de fleetview). Tag de grupo: orchestration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
9.0 KiB
Go
299 lines
9.0 KiB
Go
//go:build !windows
|
|
|
|
package infra
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// sessionFile mirrors the on-disk shape of ~/.claude/sessions/<PID>.json
|
|
// written by Claude Code 2.1.x. Only the fields we consume are declared.
|
|
type sessionFile struct {
|
|
PID int `json:"pid"`
|
|
SessionID string `json:"sessionId"`
|
|
Cwd string `json:"cwd"`
|
|
ProcStart string `json:"procStart"`
|
|
Status string `json:"status"`
|
|
UpdatedAt int64 `json:"updatedAt"`
|
|
}
|
|
|
|
// goalFile mirrors the on-disk shape of ~/.claude/goals/<sessionId>.json.
|
|
type goalFile struct {
|
|
Goal string `json:"goal"`
|
|
Phase string `json:"phase"`
|
|
Emojis string `json:"emojis"`
|
|
Rename string `json:"rename"`
|
|
DodContract string `json:"dod_contract"`
|
|
DodStatus string `json:"dod_status"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// runtimeFile mirrors ~/.claude/runtime/<sessionId>.json written by statusline.sh
|
|
// with the live context-window usage of that session.
|
|
type runtimeFile struct {
|
|
CtxPct int `json:"ctx_pct"`
|
|
}
|
|
|
|
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
|
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
|
// ListClaudeFleetFrom resolving the home directory, plus it populates each
|
|
// member's PaneID ("%N") by resolving it against the fleet tmux socket.
|
|
//
|
|
// The socket comes from $FLEET_SOCKET, defaulting to "fleet". Resolution is
|
|
// best-effort: if tmux/the socket is unavailable, every PaneID is left "" and
|
|
// the fleet is still returned. PaneID is only populated here (the public
|
|
// registry entry point), not in ListClaudeFleetFrom, so consumers that call the
|
|
// core directly in a hot loop (and the unit tests) pay no tmux cost.
|
|
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve home dir: %w", err)
|
|
}
|
|
fleet, err := ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
populatePaneIDs(fleet)
|
|
return fleet, nil
|
|
}
|
|
|
|
// populatePaneIDs resolves each alive member's pane_id ("%N") against the fleet
|
|
// tmux socket ($FLEET_SOCKET, default "fleet") and writes it into PaneID. It
|
|
// mutates fleet in place. Best-effort: tmux/socket down -> every PaneID stays ""
|
|
// (ResolvePaneIDs returns an empty map), no crash. Only alive PIDs are queried;
|
|
// a dead PID has no pane to resolve.
|
|
func populatePaneIDs(fleet []ClaudeFleet) {
|
|
socket := os.Getenv("FLEET_SOCKET")
|
|
if socket == "" {
|
|
socket = "fleet"
|
|
}
|
|
pids := make([]int, 0, len(fleet))
|
|
for _, f := range fleet {
|
|
if f.Alive {
|
|
pids = append(pids, f.PID)
|
|
}
|
|
}
|
|
byPID := ResolvePaneIDs(socket, pids)
|
|
for i := range fleet {
|
|
fleet[i].PaneID = byPID[fleet[i].PID]
|
|
}
|
|
}
|
|
|
|
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
|
// Claude Code sessions. It reads sessions/*.json, joins each against its
|
|
// goals/<sessionId>.json sidecar, validates liveness against /proc (guarding
|
|
// against PID recycling), and derives the display fields.
|
|
//
|
|
// Every session that produced a parseable JSON is returned; the Alive flag
|
|
// reflects whether the underlying process is actually running. The caller is
|
|
// expected to filter on Alive as needed. Records are ordered by status
|
|
// (idle, waiting, busy, other) and within a status by UpdatedAt descending.
|
|
func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) {
|
|
sessionsDir := filepath.Join(claudeDir, "sessions")
|
|
goalsDir := filepath.Join(claudeDir, "goals")
|
|
runtimeDir := filepath.Join(claudeDir, "runtime")
|
|
|
|
entries, err := os.ReadDir(sessionsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []ClaudeFleet{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err)
|
|
}
|
|
|
|
fleet := make([]ClaudeFleet, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
raw, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
var sess sessionFile
|
|
if json.Unmarshal(raw, &sess) != nil {
|
|
continue
|
|
}
|
|
if sess.PID == 0 || sess.SessionID == "" {
|
|
continue
|
|
}
|
|
|
|
f := ClaudeFleet{
|
|
PID: sess.PID,
|
|
SessionID: sess.SessionID,
|
|
Status: sess.Status,
|
|
Cwd: sess.Cwd,
|
|
UpdatedAt: sess.UpdatedAt,
|
|
TmuxWindow: "",
|
|
}
|
|
|
|
// Liveness + anti-PID-recycling: the process must exist AND its
|
|
// /proc starttime must match the procStart recorded in the JSON.
|
|
f.Alive = procIsAlive(sess.PID, sess.ProcStart)
|
|
|
|
// KITTY_PID from the process environ (0 if unreadable / absent).
|
|
f.KittyPID = readKittyPID(sess.PID)
|
|
|
|
// Join goal/phase/emojis/name + DoD contract/status from
|
|
// goals/<sessionId>.json (optional).
|
|
f.Goal, f.Phase, f.Emojis, f.Name, f.DodContract, f.DodStatus, f.Role = readGoal(goalsDir, sess.SessionID)
|
|
|
|
// Context usage from runtime/<sessionId>.json (written by statusline).
|
|
f.CtxPct = readCtxPct(runtimeDir, sess.SessionID)
|
|
|
|
// Derived display fields.
|
|
f.Target = deriveTarget(sess.SessionID, sess.Cwd)
|
|
f.Rename = deriveRename(f.Goal, sess.Cwd)
|
|
|
|
fleet = append(fleet, f)
|
|
}
|
|
|
|
sortFleet(fleet)
|
|
return fleet, nil
|
|
}
|
|
|
|
// procIsAlive reports whether pid is running and its kernel starttime matches
|
|
// procStartJSON. An empty procStartJSON only requires the process to exist.
|
|
func procIsAlive(pid int, procStartJSON string) bool {
|
|
real, ok := procStartTime(pid)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if procStartJSON == "" {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real)
|
|
}
|
|
|
|
// procStartTime returns field 22 (starttime, in clock ticks) of
|
|
// /proc/<pid>/stat. The comm field (field 2) is wrapped in parentheses and may
|
|
// itself contain spaces and ')' characters, so we parse the portion after the
|
|
// LAST ')' and index from there: starttime is index 20 of that remainder
|
|
// (fields 3..n), which is field 22 globally.
|
|
func procStartTime(pid int) (string, bool) {
|
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
s := string(data)
|
|
close := strings.LastIndex(s, ")")
|
|
if close < 0 || close+1 >= len(s) {
|
|
return "", false
|
|
}
|
|
rest := strings.Fields(s[close+1:])
|
|
// rest[0] = state (field 3); starttime (field 22) is index 19 here:
|
|
// field N maps to rest[N-3]. 22 - 3 = 19.
|
|
const startTimeIdx = 19
|
|
if len(rest) <= startTimeIdx {
|
|
return "", false
|
|
}
|
|
return rest[startTimeIdx], true
|
|
}
|
|
|
|
// readKittyPID parses /proc/<pid>/environ (NUL-separated KEY=VALUE pairs) and
|
|
// returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key
|
|
// is absent, or the value is not an integer.
|
|
func readKittyPID(pid int) int {
|
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, kv := range strings.Split(string(data), "\x00") {
|
|
if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok {
|
|
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
|
if convErr != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// readGoal reads goals/<sessionID>.json and returns its goal, phase, emojis,
|
|
// manual rename, DoD contract, DoD status and role. If the file is absent or
|
|
// unparseable, all are "".
|
|
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename, dodContract, dodStatus, role string) {
|
|
raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json"))
|
|
if err != nil {
|
|
return "", "", "", "", "", "", ""
|
|
}
|
|
var g goalFile
|
|
if json.Unmarshal(raw, &g) != nil {
|
|
return "", "", "", "", "", "", ""
|
|
}
|
|
return g.Goal, g.Phase, g.Emojis, g.Rename, g.DodContract, g.DodStatus, g.Role
|
|
}
|
|
|
|
// readCtxPct reads runtime/<sessionID>.json and returns the context-window used
|
|
// percentage. Returns -1 if the file is absent or unparseable (unknown).
|
|
func readCtxPct(runtimeDir, sessionID string) int {
|
|
raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json"))
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
var r runtimeFile
|
|
if json.Unmarshal(raw, &r) != nil {
|
|
return -1
|
|
}
|
|
return r.CtxPct
|
|
}
|
|
|
|
// deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is
|
|
// shorter than 8 runes it is used whole.
|
|
func deriveTarget(sessionID, cwd string) string {
|
|
short := sessionID
|
|
if r := []rune(sessionID); len(r) >= 8 {
|
|
short = string(r[:8])
|
|
}
|
|
return short + "@" + filepath.Base(cwd)
|
|
}
|
|
|
|
// deriveRename returns goal truncated to 48 runes if non-empty, else
|
|
// basename(cwd).
|
|
func deriveRename(goal, cwd string) string {
|
|
if goal != "" {
|
|
return truncateRunes(goal, 48)
|
|
}
|
|
return filepath.Base(cwd)
|
|
}
|
|
|
|
// truncateRunes returns s capped at max runes (no ellipsis).
|
|
func truncateRunes(s string, max int) string {
|
|
r := []rune(s)
|
|
if len(r) <= max {
|
|
return s
|
|
}
|
|
return string(r[:max])
|
|
}
|
|
|
|
// sortFleet orders the fleet by status rank then by UpdatedAt descending.
|
|
func sortFleet(fleet []ClaudeFleet) {
|
|
rank := func(status string) int {
|
|
switch status {
|
|
case "idle":
|
|
return 0
|
|
case "waiting":
|
|
return 1
|
|
case "busy":
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
sort.SliceStable(fleet, func(i, j int) bool {
|
|
ri, rj := rank(fleet[i].Status), rank(fleet[j].Status)
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
return fleet[i].UpdatedAt > fleet[j].UpdatedAt
|
|
})
|
|
}
|