927437a8d8
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>
260 lines
7.4 KiB
Go
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
|
|
})
|
|
}
|