//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 ) // 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//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 . Intenta primero // /proc//task//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//task//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 . 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//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 }