Files
fn_registry/functions/infra/list_claude_fleet.go
T
egutierrez fb76b53c17 feat(infra): exponer pane_id (%N) estable en el JSON de la flota
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>
2026-06-21 21:19:55 +02:00

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