Files
fn_registry/functions/infra/list_claude_fleet.go
T
egutierrez 927437a8d8 feat(infra): grupo claude-fleet — FleetView TUI + orquestacion de Claudes
Sistema FleetView para centralizar la flota de procesos Claude Code vivos en una
sola ventana kitty + tmux (socket aislado -L fleet) con un panel TUI:

- list_claude_fleet (+ tipo claude_fleet): escanea ~/.claude/sessions + goals +
  runtime, valida procesos vivos (anti-PID-reciclado), join por sessionId.
- list_resumable_claudes (+ tipo resumable_claude): sesiones cerradas reanudables.
- wrappers tmux: tmux_new_claude_window (con --resume), tmux_swap_window_into_console
  (preserva ancho del sidebar), tmux_map_claude_panes.
- launch_kittyclaude: comando entrypoint; instala atajos alt+flechas/enter/n/0/k/r,
  mouse on, remain-on-exit off; fija el ancho del sidebar con hooks.
- docs/capabilities/claude-fleet.md + entrada en el INDEX.

Incluye ademas funciones datascience en progreso (excel/duckdb/postgres) y ajustes
varios de docs e infra de otra sesion, agrupados aqui para no perderlos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:04:41 +02:00

260 lines
7.4 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"`
}
// 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.
func ListClaudeFleet() ([]ClaudeFleet, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home dir: %w", err)
}
return ListClaudeFleetFrom(filepath.Join(home, ".claude"))
}
// 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 from goals/<sessionId>.json (optional).
f.Goal, f.Phase, f.Emojis, f.Name = 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 and
// manual rename. If the file is absent or unparseable, all are "".
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename 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
}
// 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
})
}