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>
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
//go:build !windows
|
|
|
|
package infra
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ResumableClaude describes a CLOSED Claude Code session that still has a saved
|
|
// goal and can therefore be reopened with `claude --resume <SessionID>`. The
|
|
// fleetview TUI consumes these for its "resume" picker.
|
|
type ResumableClaude struct {
|
|
SessionID string `json:"session_id"`
|
|
Goal string `json:"goal"` // from goals/<id>.json .goal ("" if absent)
|
|
Emojis string `json:"emojis"` // from goals/<id>.json .emojis ("" if absent)
|
|
Name string `json:"name"` // from goals/<id>.json .rename ("" if absent)
|
|
LastActive int64 `json:"last_active"` // mtime of the goal.json file, epoch seconds
|
|
}
|
|
|
|
// maxResumable caps the number of resumable sessions returned, keeping only the
|
|
// most recently touched ones.
|
|
const maxResumable = 40
|
|
|
|
// ListResumableClaudes scans the current user's ~/.claude directory and returns
|
|
// the closed sessions that can be reopened with `claude --resume`. It is a thin
|
|
// wrapper over ListResumableClaudesFrom resolving the home directory.
|
|
func ListResumableClaudes() ([]ResumableClaude, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve home dir: %w", err)
|
|
}
|
|
return ListResumableClaudesFrom(filepath.Join(home, ".claude"))
|
|
}
|
|
|
|
// ListResumableClaudesFrom scans claudeDir (e.g. ~/.claude) and returns the
|
|
// sessions that have a goal (goals/<id>.json) whose process is NOT alive — i.e.
|
|
// candidates to reopen with `claude --resume <SessionID>`.
|
|
//
|
|
// A session is considered live (and thus excluded) when sessions/<PID>.json
|
|
// reports a PID whose /proc starttime matches the recorded procStart, using the
|
|
// exact same liveness criterion as ListClaudeFleetFrom (procIsAlive). Goals
|
|
// without a non-empty goal string are skipped. Results are ordered by
|
|
// LastActive descending and capped at maxResumable.
|
|
func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) {
|
|
sessionsDir := filepath.Join(claudeDir, "sessions")
|
|
goalsDir := filepath.Join(claudeDir, "goals")
|
|
|
|
// 1. Build the set of LIVE sessionIds from sessions/*.json.
|
|
live := liveSessionIDs(sessionsDir)
|
|
|
|
// 2. Scan goals/*.json.
|
|
entries, err := os.ReadDir(goalsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []ResumableClaude{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read goals dir %q: %w", goalsDir, err)
|
|
}
|
|
|
|
out := make([]ResumableClaude, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if entry.IsDir() || !strings.HasSuffix(name, ".json") {
|
|
continue
|
|
}
|
|
sessionID := strings.TrimSuffix(name, ".json")
|
|
if sessionID == "" {
|
|
continue
|
|
}
|
|
// Skip sessions that are alive (already in the fleet, not resumable).
|
|
if live[sessionID] {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(goalsDir, name)
|
|
raw, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
var g goalFile
|
|
if json.Unmarshal(raw, &g) != nil {
|
|
continue
|
|
}
|
|
// No real work to resume without a goal.
|
|
if strings.TrimSpace(g.Goal) == "" {
|
|
continue
|
|
}
|
|
|
|
info, statErr := os.Stat(path)
|
|
if statErr != nil {
|
|
continue
|
|
}
|
|
|
|
out = append(out, ResumableClaude{
|
|
SessionID: sessionID,
|
|
Goal: g.Goal,
|
|
Emojis: g.Emojis,
|
|
Name: g.Rename,
|
|
LastActive: info.ModTime().Unix(),
|
|
})
|
|
}
|
|
|
|
// 3. Order by LastActive descending (most recent first).
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return out[i].LastActive > out[j].LastActive
|
|
})
|
|
|
|
// 4. Cap at maxResumable.
|
|
if len(out) > maxResumable {
|
|
out = out[:maxResumable]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// liveSessionIDs scans sessionsDir (sessions/*.json) and returns the set of
|
|
// sessionIds whose process is currently alive, applying the same anti-PID-
|
|
// recycling check as ListClaudeFleetFrom (procIsAlive matches /proc starttime
|
|
// against the recorded procStart). Missing or unparseable files are ignored.
|
|
func liveSessionIDs(sessionsDir string) map[string]bool {
|
|
live := make(map[string]bool)
|
|
entries, err := os.ReadDir(sessionsDir)
|
|
if err != nil {
|
|
return live
|
|
}
|
|
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
|
|
}
|
|
if procIsAlive(sess.PID, sess.ProcStart) {
|
|
live[sess.SessionID] = true
|
|
}
|
|
}
|
|
return live
|
|
}
|