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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxMapClaudePanes devuelve un mapa claudePID -> window_id de todos los panes
|
||||
// del socket cuyo proceso de pane (o algun descendiente directo) sea un proceso
|
||||
// `claude`. Permite a la TUI saber que Claude de su lista ya vive en la sesion
|
||||
// fleet (y por tanto es conmutable) y en que window.
|
||||
//
|
||||
// Como cada pane que corre Claude lo hace con `exec claude ...`, el #{pane_pid}
|
||||
// del pane normalmente ES el PID de claude (comm == "claude"). Por robustez, si
|
||||
// el propio pane_pid no es claude (p.ej. un shell que lanzo claude como hijo),
|
||||
// se recorren sus descendientes directos buscando el primer comm == "claude".
|
||||
// Si no se encuentra claude bajo un pane, ese pane se omite.
|
||||
//
|
||||
// Opera SIEMPRE sobre el socket aislado pasado como parametro (tmux -L <socket>)
|
||||
// y lee /proc (no portable a Windows; de ahi el build tag //go:build !windows).
|
||||
func TmuxMapClaudePanes(socket string) (map[int]string, error) {
|
||||
if socket == "" {
|
||||
return nil, fmt.Errorf("tmux_map_claude_panes: socket vacio")
|
||||
}
|
||||
|
||||
out, stderr, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{window_id}")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tmux_map_claude_panes: list-panes -a: %w (%s)", err, stderr)
|
||||
}
|
||||
|
||||
result := make(map[int]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
panePID, convErr := strconv.Atoi(fields[0])
|
||||
if convErr != nil {
|
||||
continue
|
||||
}
|
||||
windowID := fields[1]
|
||||
|
||||
claudePID, ok := findClaudePID(panePID)
|
||||
if !ok {
|
||||
continue // no hay claude bajo este pane
|
||||
}
|
||||
result[claudePID] = windowID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findClaudePID devuelve el PID de un proceso `claude` que sea el propio pid o
|
||||
// un hijo directo suyo. Devuelve (pid, true) si lo encuentra; (0, false) si no.
|
||||
func findClaudePID(pid int) (int, bool) {
|
||||
if procComm(pid) == "claude" {
|
||||
return pid, true
|
||||
}
|
||||
for _, child := range procChildren(pid) {
|
||||
if procComm(child) == "claude" {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// procComm lee el nombre del comando (comm) de /proc/<pid>/comm. Devuelve ""
|
||||
// si el proceso no existe o no se puede leer.
|
||||
func procComm(pid int) string {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// procChildren devuelve los PIDs de los hijos DIRECTOS de <pid>. Intenta primero
|
||||
// /proc/<pid>/task/<pid>/children (rapido, requiere CONFIG_PROC_CHILDREN); si no
|
||||
// esta disponible, cae a escanear /proc/*/stat por PPID (campo 4).
|
||||
func procChildren(pid int) []int {
|
||||
if kids := procChildrenFromTask(pid); kids != nil {
|
||||
return kids
|
||||
}
|
||||
return procChildrenFromScan(pid)
|
||||
}
|
||||
|
||||
// procChildrenFromTask agrega /proc/<pid>/task/<tid>/children sobre TODOS los
|
||||
// hilos (tasks) del proceso. Cada `children` lista solo los hijos parenteados
|
||||
// a ESE task, asi que un proceso multihilo (un shell que hizo fork desde un
|
||||
// hilo no principal, o el propio test runner de Go) puede tener hijos repartidos
|
||||
// entre varios tasks. Devuelve nil si el directorio task/ no existe o ningun
|
||||
// task expone `children` (kernel sin CONFIG_PROC_CHILDREN), para que el caller
|
||||
// use el fallback de scan por PPID.
|
||||
func procChildrenFromTask(pid int) []int {
|
||||
taskDir := fmt.Sprintf("/proc/%d/task", pid)
|
||||
tasks, err := os.ReadDir(taskDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var kids []int
|
||||
supported := false
|
||||
for _, task := range tasks {
|
||||
tid := task.Name()
|
||||
data, err := os.ReadFile(filepath.Join(taskDir, tid, "children"))
|
||||
if err != nil {
|
||||
continue // este task no expone children; probar el resto
|
||||
}
|
||||
supported = true
|
||||
for _, tok := range strings.Fields(string(data)) {
|
||||
if k, err := strconv.Atoi(tok); err == nil {
|
||||
kids = append(kids, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
return nil // kernel sin CONFIG_PROC_CHILDREN -> fallback a scan
|
||||
}
|
||||
// Distinguir "sin hijos" (slice vacio no-nil) de "sin soporte" (nil arriba).
|
||||
if kids == nil {
|
||||
return []int{}
|
||||
}
|
||||
return kids
|
||||
}
|
||||
|
||||
// procChildrenFromScan escanea /proc/*/stat buscando procesos cuyo PPID (campo
|
||||
// 4 de stat, indice 1 tras el comm entre parentesis) sea <pid>.
|
||||
func procChildrenFromScan(parent int) []int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var kids []int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
childPID, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue // no es un directorio de PID
|
||||
}
|
||||
if procPPID(childPID) == parent {
|
||||
kids = append(kids, childPID)
|
||||
}
|
||||
}
|
||||
return kids
|
||||
}
|
||||
|
||||
// procPPID extrae el PPID (campo 4 de /proc/<pid>/stat). El comm (campo 2) va
|
||||
// entre parentesis y puede contener espacios y ')', asi que se parsea tomando
|
||||
// lo que hay tras el ULTIMO ')'. Tras el comm, los campos son: state(0) ppid(1)
|
||||
// pgrp(2)... -> el PPID es el indice 1 de ese resto.
|
||||
func procPPID(pid int) int {
|
||||
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 {
|
||||
return -1
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
const ppidIdx = 1 // state=rest[0], ppid=rest[1]
|
||||
if len(rest) <= ppidIdx {
|
||||
return -1
|
||||
}
|
||||
ppid, err := strconv.Atoi(rest[ppidIdx])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return ppid
|
||||
}
|
||||
Reference in New Issue
Block a user