Files
fn_registry/functions/infra/resumable_claude.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

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
}