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

181 lines
5.3 KiB
Go

//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
}