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:
@@ -73,6 +73,10 @@ Cuando necesitas ver qué errores, warnings o mensajes de consola produce una p
|
||||
- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí.
|
||||
- **`Log.enable` puede no estar disponible** en algunos targets (workers, ciertos contextos). Si falla, la función NO aborta: sigue capturando `Runtime.*` y solo pierde los avisos de `Log.entryAdded`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (16/06/2026) — añade parámetro `maxEntries` (cap, default 200) + filtro de backlog por timestamp. Resuelve bug real: en conexiones del pool con `Runtime` ya habilitado, el flush de `Runtime.enable` arrastraba eventos históricos (cientos en páginas verbosas con `setInterval`) que reventaban el output. Ahora se descarta lo anterior a `startMs` y se acota la salida con señal `_truncated`.
|
||||
|
||||
## Notas
|
||||
|
||||
`ConsoleEntry` se define como tipo simple del package `browser` en el mismo `.go` (igual que `HarEntry`/`HarHeader` en `cdp_har_record.go`), no como tipo del registry — evita import circular y mantiene la firma autosuficiente. La acumulación usa un `sync.Mutex` porque los handlers de `OnEvent` corren en la goroutine del `readLoop` de `CDPConn`, concurrente con la goroutine que duerme la ventana. La conversión de args de `consoleAPICalled` serializa objetos/arrays a JSON real (no la repr `%v` de Go) para que datos estructurados sean parseables.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
// ClaudeFleet describes a single Claude Code session process on the local
|
||||
// machine, cross-joining the live process state (/proc) with the session and
|
||||
// goal metadata that Claude Code persists under ~/.claude.
|
||||
//
|
||||
// It is the data record consumed by the fleetview TUI. Every field is derived
|
||||
// from a single ~/.claude/sessions/<PID>.json entry plus its optional
|
||||
// ~/.claude/goals/<sessionId>.json sidecar and the process' own /proc entry.
|
||||
type ClaudeFleet struct {
|
||||
PID int `json:"pid"`
|
||||
KittyPID int `json:"kitty_pid"` // KITTY_PID from the process environ; 0 if not applicable (e.g. remote tmux)
|
||||
SessionID string `json:"session_id"` // Claude Code sessionId (UUID)
|
||||
Rename string `json:"rename"` // display name: short goal if present, else basename(cwd)
|
||||
Target string `json:"target"` // sessionId[:8] + "@" + basename(cwd)
|
||||
Goal string `json:"goal"` // from goals/<sessionId>.json .goal ("" if absent)
|
||||
Phase string `json:"phase"` // from goals/<sessionId>.json .phase ("" if absent)
|
||||
Emojis string `json:"emojis"` // 3 emojis representing the task (from goals .emojis; "" if absent)
|
||||
Name string `json:"name"` // manual rename of the terminal (from goals .rename; "" if none)
|
||||
Status string `json:"status"` // idle|busy|waiting (from sessions/<pid>.json)
|
||||
Cwd string `json:"cwd"` // working directory of the session
|
||||
TmuxWindow string `json:"tmux_window"` // "" for now (populated in a later phase)
|
||||
Alive bool `json:"alive"` // process alive AND procStart matches (guards against PID recycling)
|
||||
UpdatedAt int64 `json:"updated_at"` // from sessions/<pid>.json .updatedAt (epoch millis)
|
||||
CtxPct int `json:"ctx_pct"` // context window used %, from runtime/<sessionId>.json; -1 if unknown
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sessionFile mirrors the on-disk shape of ~/.claude/sessions/<PID>.json
|
||||
// written by Claude Code 2.1.x. Only the fields we consume are declared.
|
||||
type sessionFile struct {
|
||||
PID int `json:"pid"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
ProcStart string `json:"procStart"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// goalFile mirrors the on-disk shape of ~/.claude/goals/<sessionId>.json.
|
||||
type goalFile struct {
|
||||
Goal string `json:"goal"`
|
||||
Phase string `json:"phase"`
|
||||
Emojis string `json:"emojis"`
|
||||
Rename string `json:"rename"`
|
||||
}
|
||||
|
||||
// runtimeFile mirrors ~/.claude/runtime/<sessionId>.json written by statusline.sh
|
||||
// with the live context-window usage of that session.
|
||||
type runtimeFile struct {
|
||||
CtxPct int `json:"ctx_pct"`
|
||||
}
|
||||
|
||||
// ListClaudeFleet scans the current user's ~/.claude directory and returns the
|
||||
// fleet of Claude Code sessions known to the machine. It is a thin wrapper over
|
||||
// ListClaudeFleetFrom resolving the home directory.
|
||||
func ListClaudeFleet() ([]ClaudeFleet, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home dir: %w", err)
|
||||
}
|
||||
return ListClaudeFleetFrom(filepath.Join(home, ".claude"))
|
||||
}
|
||||
|
||||
// ListClaudeFleetFrom scans claudeDir (e.g. ~/.claude) and returns the fleet of
|
||||
// Claude Code sessions. It reads sessions/*.json, joins each against its
|
||||
// goals/<sessionId>.json sidecar, validates liveness against /proc (guarding
|
||||
// against PID recycling), and derives the display fields.
|
||||
//
|
||||
// Every session that produced a parseable JSON is returned; the Alive flag
|
||||
// reflects whether the underlying process is actually running. The caller is
|
||||
// expected to filter on Alive as needed. Records are ordered by status
|
||||
// (idle, waiting, busy, other) and within a status by UpdatedAt descending.
|
||||
func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) {
|
||||
sessionsDir := filepath.Join(claudeDir, "sessions")
|
||||
goalsDir := filepath.Join(claudeDir, "goals")
|
||||
runtimeDir := filepath.Join(claudeDir, "runtime")
|
||||
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []ClaudeFleet{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read sessions dir %q: %w", sessionsDir, err)
|
||||
}
|
||||
|
||||
fleet := make([]ClaudeFleet, 0, len(entries))
|
||||
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
|
||||
}
|
||||
|
||||
f := ClaudeFleet{
|
||||
PID: sess.PID,
|
||||
SessionID: sess.SessionID,
|
||||
Status: sess.Status,
|
||||
Cwd: sess.Cwd,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
TmuxWindow: "",
|
||||
}
|
||||
|
||||
// Liveness + anti-PID-recycling: the process must exist AND its
|
||||
// /proc starttime must match the procStart recorded in the JSON.
|
||||
f.Alive = procIsAlive(sess.PID, sess.ProcStart)
|
||||
|
||||
// KITTY_PID from the process environ (0 if unreadable / absent).
|
||||
f.KittyPID = readKittyPID(sess.PID)
|
||||
|
||||
// Join goal/phase/emojis/name from goals/<sessionId>.json (optional).
|
||||
f.Goal, f.Phase, f.Emojis, f.Name = readGoal(goalsDir, sess.SessionID)
|
||||
|
||||
// Context usage from runtime/<sessionId>.json (written by statusline).
|
||||
f.CtxPct = readCtxPct(runtimeDir, sess.SessionID)
|
||||
|
||||
// Derived display fields.
|
||||
f.Target = deriveTarget(sess.SessionID, sess.Cwd)
|
||||
f.Rename = deriveRename(f.Goal, sess.Cwd)
|
||||
|
||||
fleet = append(fleet, f)
|
||||
}
|
||||
|
||||
sortFleet(fleet)
|
||||
return fleet, nil
|
||||
}
|
||||
|
||||
// procIsAlive reports whether pid is running and its kernel starttime matches
|
||||
// procStartJSON. An empty procStartJSON only requires the process to exist.
|
||||
func procIsAlive(pid int, procStartJSON string) bool {
|
||||
real, ok := procStartTime(pid)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if procStartJSON == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(procStartJSON) == strings.TrimSpace(real)
|
||||
}
|
||||
|
||||
// procStartTime returns field 22 (starttime, in clock ticks) of
|
||||
// /proc/<pid>/stat. The comm field (field 2) is wrapped in parentheses and may
|
||||
// itself contain spaces and ')' characters, so we parse the portion after the
|
||||
// LAST ')' and index from there: starttime is index 20 of that remainder
|
||||
// (fields 3..n), which is field 22 globally.
|
||||
func procStartTime(pid int) (string, bool) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 || close+1 >= len(s) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
// rest[0] = state (field 3); starttime (field 22) is index 19 here:
|
||||
// field N maps to rest[N-3]. 22 - 3 = 19.
|
||||
const startTimeIdx = 19
|
||||
if len(rest) <= startTimeIdx {
|
||||
return "", false
|
||||
}
|
||||
return rest[startTimeIdx], true
|
||||
}
|
||||
|
||||
// readKittyPID parses /proc/<pid>/environ (NUL-separated KEY=VALUE pairs) and
|
||||
// returns the KITTY_PID value. Returns 0 if the environ is unreadable, the key
|
||||
// is absent, or the value is not an integer.
|
||||
func readKittyPID(pid int) int {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, kv := range strings.Split(string(data), "\x00") {
|
||||
if v, ok := strings.CutPrefix(kv, "KITTY_PID="); ok {
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readGoal reads goals/<sessionID>.json and returns its goal, phase, emojis and
|
||||
// manual rename. If the file is absent or unparseable, all are "".
|
||||
func readGoal(goalsDir, sessionID string) (goal, phase, emojis, rename string) {
|
||||
raw, err := os.ReadFile(filepath.Join(goalsDir, sessionID+".json"))
|
||||
if err != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
var g goalFile
|
||||
if json.Unmarshal(raw, &g) != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
return g.Goal, g.Phase, g.Emojis, g.Rename
|
||||
}
|
||||
|
||||
// readCtxPct reads runtime/<sessionID>.json and returns the context-window used
|
||||
// percentage. Returns -1 if the file is absent or unparseable (unknown).
|
||||
func readCtxPct(runtimeDir, sessionID string) int {
|
||||
raw, err := os.ReadFile(filepath.Join(runtimeDir, sessionID+".json"))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
var r runtimeFile
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
return -1
|
||||
}
|
||||
return r.CtxPct
|
||||
}
|
||||
|
||||
// deriveTarget builds sessionID[:8] + "@" + basename(cwd). If sessionID is
|
||||
// shorter than 8 runes it is used whole.
|
||||
func deriveTarget(sessionID, cwd string) string {
|
||||
short := sessionID
|
||||
if r := []rune(sessionID); len(r) >= 8 {
|
||||
short = string(r[:8])
|
||||
}
|
||||
return short + "@" + filepath.Base(cwd)
|
||||
}
|
||||
|
||||
// deriveRename returns goal truncated to 48 runes if non-empty, else
|
||||
// basename(cwd).
|
||||
func deriveRename(goal, cwd string) string {
|
||||
if goal != "" {
|
||||
return truncateRunes(goal, 48)
|
||||
}
|
||||
return filepath.Base(cwd)
|
||||
}
|
||||
|
||||
// truncateRunes returns s capped at max runes (no ellipsis).
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// sortFleet orders the fleet by status rank then by UpdatedAt descending.
|
||||
func sortFleet(fleet []ClaudeFleet) {
|
||||
rank := func(status string) int {
|
||||
switch status {
|
||||
case "idle":
|
||||
return 0
|
||||
case "waiting":
|
||||
return 1
|
||||
case "busy":
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
sort.SliceStable(fleet, func(i, j int) bool {
|
||||
ri, rj := rank(fleet[i].Status), rank(fleet[j].Status)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return fleet[i].UpdatedAt > fleet[j].UpdatedAt
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: list_claude_fleet
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListClaudeFleetFrom(claudeDir string) ([]ClaudeFleet, error) | func ListClaudeFleet() ([]ClaudeFleet, error)"
|
||||
description: "Lista la flota de procesos Claude Code de la maquina local (Linux). Escanea ~/.claude/sessions/*.json, cruza cada PID vivo contra /proc para validar liveness (anti-PID-reciclado via procStart == campo 22 de /proc/<pid>/stat), une el goal/phase de ~/.claude/goals/<sessionId>.json, extrae KITTY_PID del environ y deriva los campos de display (Target, Rename). Devuelve todas las sesiones ordenadas por status (idle, waiting, busy, otro) y por updatedAt desc; el caller filtra por Alive. Pieza de datos de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, proc, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: [claude_fleet_go_infra]
|
||||
returns: [claude_fleet_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListClaudeFleetFrom lo recibe explicito (testeable con t.TempDir()); ListClaudeFleet lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ClaudeFleet (claude_fleet_go_infra), una entrada por sesion con JSON parseable en sessions/. Cada entrada lleva PID, KittyPID, SessionID, Rename, Target, Goal, Phase, Status, Cwd, TmuxWindow (\"\"), Alive y UpdatedAt. Ordenado por rango de status y luego por UpdatedAt descendente. Devuelve slice vacio (sin error) si la carpeta sessions/ no existe; error si no se puede leer la carpeta por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListClaudeFleetFrom", "TestListClaudeFleetFromMissingDir"]
|
||||
test_file_path: "functions/infra/list_claude_fleet_test.go"
|
||||
file_path: "functions/infra/list_claude_fleet.go"
|
||||
notes: "Misma fuente de verdad que reboot_all_claudes_bash_infra (~/.claude/sessions/<PID>.json de Claude Code 2.1.x: pid, sessionId, cwd, procStart, status, updatedAt). Solo LEE y valida — no relanza ni mata nada. La validacion anti-PID-reciclado replica la del bash (procStart del JSON vs campo 22 de /proc/<pid>/stat) pero parseando de forma robusta el comm (campo 2 entre parentesis, que puede contener espacios y ')'): se toma lo que hay tras el ULTIMO ')' y starttime es el indice 19 de ese resto. TmuxWindow queda \"\" (se rellena en una fase posterior). Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fleet, err := infra.ListClaudeFleet() // escanea ~/.claude
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, c := range fleet {
|
||||
if !c.Alive {
|
||||
continue // el caller filtra las sesiones muertas
|
||||
}
|
||||
fmt.Printf("[%s] %-20s pid=%d kitty=%d %s\n",
|
||||
c.Status, c.Rename, c.PID, c.KittyPID, c.Target)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||
fleet, _ := infra.ListClaudeFleetFrom("/home/enmanuel/.claude")
|
||||
fmt.Println(len(fleet), "sesiones conocidas")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las sesiones de Claude Code vivas en la maquina local para mostrarlas, monitorizarlas o actuar sobre ellas (TUI fleetview, dashboards, automatizaciones). Da el join PID -> sessionId -> cwd -> goal/phase ya resuelto y validado contra /proc, en lugar de reimplementarlo a mano cada vez. Usa `ListClaudeFleetFrom` en tests (inyectando un directorio con fixtures) y `ListClaudeFleet` en runtime real.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca mata ni relanza procesos.
|
||||
- **Anti-PID-reciclado.** `Alive` solo es true si el proceso existe Y su starttime (campo 22 de `/proc/<pid>/stat`) coincide con el `procStart` del JSON. Un JSON huerfano cuyo PID fue reasignado a otro proceso se marca `Alive=false` aunque ese PID este vivo. Si el JSON no trae `procStart`, basta con que el proceso exista.
|
||||
- **Parseo del `comm` en /proc/<pid>/stat.** El campo 2 (comm) va entre parentesis y puede contener espacios y el caracter ')'. La funcion parsea tomando lo que hay tras el ULTIMO ')'; un split ingenuo por espacios daria un starttime equivocado.
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` y `/proc/<pid>/environ` (Linux). En macOS/BSD no funciona tal cual.
|
||||
- **environ ilegible -> KittyPID=0.** Si `/proc/<pid>/environ` no es legible (permisos, proceso de otro usuario, o el proceso ya murio entre el ReadDir y el ReadFile) `KittyPID` cae a 0 sin error. Tambien es 0 legitimamente cuando claude no corre bajo kitty (ej. tmux remoto).
|
||||
- **Devuelve TODAS las sesiones con JSON parseable**, vivas o muertas. El caller decide filtrar por `Alive`. Archivos no-`.json` y JSON corrupto se ignoran silenciosamente.
|
||||
- **TmuxWindow siempre "".** Reservado para una fase posterior; hoy no se rellena.
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readOwnProcStart reads field 22 (starttime) of /proc/<pid>/stat for the
|
||||
// current test process, so a fixture can be marked Alive deterministically.
|
||||
func readOwnProcStart(t *testing.T, pid int) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
t.Fatalf("read own /proc/%d/stat: %v", pid, err)
|
||||
}
|
||||
s := string(data)
|
||||
close := strings.LastIndex(s, ")")
|
||||
if close < 0 {
|
||||
t.Fatalf("malformed stat line: %q", s)
|
||||
}
|
||||
rest := strings.Fields(s[close+1:])
|
||||
const startTimeIdx = 19 // field 22 == rest[22-3]
|
||||
if len(rest) <= startTimeIdx {
|
||||
t.Fatalf("stat has too few fields after comm: %d", len(rest))
|
||||
}
|
||||
return rest[startTimeIdx]
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeFleetFrom(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
sessions := filepath.Join(tmp, "sessions")
|
||||
goals := filepath.Join(tmp, "goals")
|
||||
|
||||
livePID := os.Getpid()
|
||||
liveProcStart := readOwnProcStart(t, livePID)
|
||||
|
||||
const deadPID = 2147480000 // implausibly high; no such process
|
||||
|
||||
// Session A: alive (own PID), with a goal -> rename = truncated goal,
|
||||
// status idle. cwd basename = fn_registry.
|
||||
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", livePID)),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"aaaaaaaa-1111-2222-3333-444444444444","cwd":"/home/enmanuel/fn_registry","procStart":%q,"status":"idle","updatedAt":1000}`,
|
||||
livePID, liveProcStart))
|
||||
writeFile(t, filepath.Join(goals, "aaaaaaaa-1111-2222-3333-444444444444.json"),
|
||||
`{"goal":"Recomendar stack tecnologico para la nueva app de inventario y validar dependencias","phase":"investigando","history":["haciendo","investigando"]}`)
|
||||
|
||||
// Session B: alive (own PID again — same process, valid procStart), no
|
||||
// goal sidecar -> rename = basename(cwd) = projectx, status busy.
|
||||
writeFile(t, filepath.Join(sessions, "b.json"),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"bbbbbbbb-5555","cwd":"/var/tmp/projectx","procStart":%q,"status":"busy","updatedAt":2000}`,
|
||||
livePID, liveProcStart))
|
||||
|
||||
// Session C: dead PID -> Alive=false, status waiting, has goal.
|
||||
writeFile(t, filepath.Join(sessions, fmt.Sprintf("%d.json", deadPID)),
|
||||
fmt.Sprintf(`{"pid":%d,"sessionId":"cccccccc-9999-0000","cwd":"/srv/work/zeta","procStart":"99999999","status":"waiting","updatedAt":3000}`,
|
||||
deadPID))
|
||||
writeFile(t, filepath.Join(goals, "cccccccc-9999-0000.json"),
|
||||
`{"goal":"limpiar logs","phase":"haciendo"}`)
|
||||
|
||||
// Noise files that must be ignored.
|
||||
writeFile(t, filepath.Join(sessions, "notjson.txt"), "ignore me")
|
||||
writeFile(t, filepath.Join(sessions, "broken.json"), "{ this is not json")
|
||||
|
||||
fleet, err := ListClaudeFleetFrom(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("ListClaudeFleetFrom: %v", err)
|
||||
}
|
||||
if len(fleet) != 3 {
|
||||
t.Fatalf("expected 3 sessions, got %d: %+v", len(fleet), fleet)
|
||||
}
|
||||
|
||||
by := map[string]ClaudeFleet{}
|
||||
for _, f := range fleet {
|
||||
by[f.SessionID] = f
|
||||
}
|
||||
|
||||
// --- Session A assertions ---
|
||||
a := by["aaaaaaaa-1111-2222-3333-444444444444"]
|
||||
if !a.Alive {
|
||||
t.Errorf("session A: expected Alive=true (own PID + matching procStart)")
|
||||
}
|
||||
if a.Goal != "Recomendar stack tecnologico para la nueva app de inventario y validar dependencias" {
|
||||
t.Errorf("session A: goal join failed, got %q", a.Goal)
|
||||
}
|
||||
if a.Phase != "investigando" {
|
||||
t.Errorf("session A: phase join failed, got %q", a.Phase)
|
||||
}
|
||||
// Rename = goal truncated to 48 runes.
|
||||
wantRename := string([]rune(a.Goal)[:48])
|
||||
if a.Rename != wantRename {
|
||||
t.Errorf("session A: rename = %q, want truncated goal %q", a.Rename, wantRename)
|
||||
}
|
||||
if len([]rune(a.Rename)) != 48 {
|
||||
t.Errorf("session A: rename should be 48 runes, got %d", len([]rune(a.Rename)))
|
||||
}
|
||||
if a.Target != "aaaaaaaa@fn_registry" {
|
||||
t.Errorf("session A: target = %q, want %q", a.Target, "aaaaaaaa@fn_registry")
|
||||
}
|
||||
|
||||
// --- Session B assertions: no goal -> fallback rename = basename(cwd) ---
|
||||
b := by["bbbbbbbb-5555"]
|
||||
if b.Goal != "" || b.Phase != "" {
|
||||
t.Errorf("session B: expected empty goal/phase, got goal=%q phase=%q", b.Goal, b.Phase)
|
||||
}
|
||||
if b.Rename != "projectx" {
|
||||
t.Errorf("session B: rename = %q, want basename(cwd) %q", b.Rename, "projectx")
|
||||
}
|
||||
if b.Target != "bbbbbbbb@projectx" {
|
||||
t.Errorf("session B: target = %q, want %q", b.Target, "bbbbbbbb@projectx")
|
||||
}
|
||||
if !b.Alive {
|
||||
t.Errorf("session B: expected Alive=true (own PID + matching procStart)")
|
||||
}
|
||||
|
||||
// --- Session C assertions: dead PID ---
|
||||
c := by["cccccccc-9999-0000"]
|
||||
if c.Alive {
|
||||
t.Errorf("session C: expected Alive=false for dead PID %d", deadPID)
|
||||
}
|
||||
if c.Target != "cccccccc@zeta" {
|
||||
t.Errorf("session C: target = %q, want %q", c.Target, "cccccccc@zeta")
|
||||
}
|
||||
|
||||
// --- Ordering: status rank idle(0) < waiting(1) < busy(2) ---
|
||||
// A=idle, C=waiting, B=busy => expected order A, C, B.
|
||||
wantOrder := []string{
|
||||
"aaaaaaaa-1111-2222-3333-444444444444",
|
||||
"cccccccc-9999-0000",
|
||||
"bbbbbbbb-5555",
|
||||
}
|
||||
for i, want := range wantOrder {
|
||||
if fleet[i].SessionID != want {
|
||||
t.Errorf("order[%d] = %q (status %q), want %q", i, fleet[i].SessionID, fleet[i].Status, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeFleetFromMissingDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
fleet, err := ListClaudeFleetFrom(filepath.Join(tmp, "nope"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for missing sessions dir, got %v", err)
|
||||
}
|
||||
if len(fleet) != 0 {
|
||||
t.Fatalf("expected empty fleet, got %d", len(fleet))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: list_resumable_claudes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ListResumableClaudesFrom(claudeDir string) ([]ResumableClaude, error) | func ListResumableClaudes() ([]ResumableClaude, error)"
|
||||
description: "Lista las sesiones de Claude Code CERRADAS que se pueden reabrir con `claude --resume <sessionId>` (Linux). Escanea ~/.claude/sessions/*.json para construir el conjunto de sessionIds VIVOS (mismo criterio anti-PID-reciclado que list_claude_fleet: procStart == campo 22 de /proc/<pid>/stat), luego recorre ~/.claude/goals/*.json y devuelve cada sesion cuyo proceso NO esta vivo y que tiene un goal no vacio. Cada entrada lleva session_id, goal, emojis y name (rename) del goal.json, y last_active = mtime del goal.json. Ordenadas por last_active desc y limitadas a 40. Pieza de datos del picker de resume de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, claude, session, resume, proc, tui]
|
||||
uses_functions: [list_claude_fleet_go_infra]
|
||||
uses_types: [resumable_claude_go_infra]
|
||||
returns: [resumable_claude_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "claudeDir"
|
||||
desc: "Directorio raiz de Claude Code a escanear (ej. /home/enmanuel/.claude). ListResumableClaudesFrom lo recibe explicito (testeable con t.TempDir()); ListResumableClaudes lo resuelve via os.UserHomeDir() + .claude."
|
||||
output: "Slice de ResumableClaude (resumable_claude_go_infra), una entrada por sesion CERRADA con goal en goals/<id>.json. Cada entrada lleva SessionID (basename del goal.json sin .json), Goal, Emojis, Name (rename) y LastActive (mtime del goal.json en epoch segundos). Excluye las sesiones cuyo proceso sigue vivo (ya en la flota) y las que no tienen goal. Ordenado por LastActive descendente y capado a 40 resultados. Devuelve slice vacio (sin error) si la carpeta goals/ no existe; error si no se puede leer por otra causa."
|
||||
tested: true
|
||||
tests: ["TestListResumableClaudesFrom"]
|
||||
test_file_path: "functions/infra/resumable_claude_test.go"
|
||||
file_path: "functions/infra/resumable_claude.go"
|
||||
notes: "Complementaria de list_claude_fleet_go_infra: aquella lista las sesiones VIVAS, esta las CERRADAS-pero-resumibles. Reutiliza los helpers procIsAlive/procStartTime del mismo paquete infra (definidos en functions/infra/list_claude_fleet.go) — no los redefine. El conjunto de vivos se construye desde sessions/*.json; el catalogo de candidatas desde goals/*.json. El sessionId de una candidata es el basename del goal.json (no hay sessions/<PID>.json para ella porque su proceso ya murio). LastActive es el mtime del archivo, no la actividad real de la conversacion. Build tag //go:build !windows (depende de /proc, no portable a Windows)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resumables, err := infra.ListResumableClaudes() // escanea ~/.claude
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, r := range resumables {
|
||||
fmt.Printf("%s %-40s claude --resume %s\n", r.Emojis, r.Goal, r.SessionID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Variante testeable: escanea un directorio arbitrario (fixtures en tests).
|
||||
resumables, _ := infra.ListResumableClaudesFrom("/home/enmanuel/.claude")
|
||||
fmt.Println(len(resumables), "sesiones reabribles")
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites poblar un picker de "reanudar" en la TUI fleetview (o cualquier UI/automatizacion equivalente): te da las sesiones de Claude Code que ya cerraste pero que tenian un objetivo guardado, listas para `claude --resume <session_id>`. Excluye las que siguen vivas (esas ya estan en la flota, las lista `list_claude_fleet_go_infra`). Usa `ListResumableClaudesFrom` en tests (inyectando un directorio con fixtures) y `ListResumableClaudes` en runtime real.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: lee el filesystem y /proc.** No es determinista entre llamadas (las sesiones nacen y mueren). Solo lectura — nunca relanza ni mata nada.
|
||||
- **El statusline purga goals viejos.** Las sesiones de mas de ~7 dias suelen tener su `goals/<id>.json` purgado por el statusline, asi que dejan de aparecer aqui aunque `claude --resume` siga pudiendo reabrirlas. Esta funcion solo ve lo que queda en `goals/`.
|
||||
- **PID reciclado.** El conjunto de "vivos" usa el mismo guardado anti-PID-reciclado que `list_claude_fleet`: un PID reasignado a otro proceso NO marca la sesion como viva (procStart != campo 22 de /proc/<pid>/stat), por lo que su goal seguira saliendo como resumible correctamente.
|
||||
- **Orden por mtime, no por actividad real.** `LastActive` es el `mtime` del `goal.json`, que se toca cuando el statusline reescribe el objetivo/fase — no es el instante exacto del ultimo mensaje de la conversacion. Es una aproximacion "lo mas reciente arriba", no un timestamp exacto de actividad.
|
||||
- **Cap a 40.** Solo se devuelven las 40 mas recientes; si hay mas goals cerrados, los antiguos se omiten.
|
||||
- **Goals sin goal o ilegibles se omiten** silenciosamente. Un `goal.json` con `goal` vacio (o solo espacios) no es resumible (no hay trabajo que reanudar). Archivos no-`.json` y JSON corrupto se ignoran.
|
||||
- **/proc no es portable.** Build tag `//go:build !windows`; depende de `/proc/<pid>/stat` (Linux) para decidir que sesiones estan vivas.
|
||||
@@ -0,0 +1,172 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeJSON marshals v and writes it to path, failing the test on error.
|
||||
func writeJSON(t *testing.T, path string, v any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
t.Fatalf("write %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// touch sets the mtime of path to the given unix epoch seconds.
|
||||
func touch(t *testing.T, path string, epoch int64) {
|
||||
t.Helper()
|
||||
mt := time.Unix(epoch, 0)
|
||||
if err := os.Chtimes(path, mt, mt); err != nil {
|
||||
t.Fatalf("chtimes %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResumableClaudesFrom(t *testing.T) {
|
||||
t.Run("excluye sesion viva, incluye muertas con goal ordenadas por LastActive", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionsDir := filepath.Join(dir, "sessions")
|
||||
goalsDir := filepath.Join(dir, "goals")
|
||||
|
||||
// A LIVE session: real running PID (this test process) + its real
|
||||
// /proc starttime as procStart, so procIsAlive returns true.
|
||||
livePID := os.Getpid()
|
||||
liveStart, ok := procStartTime(livePID)
|
||||
if !ok {
|
||||
t.Fatalf("could not read procStartTime for self pid %d", livePID)
|
||||
}
|
||||
const liveSession = "11111111-aaaa-bbbb-cccc-000000000001"
|
||||
writeJSON(t, filepath.Join(sessionsDir, "9001.json"), sessionFile{
|
||||
PID: livePID,
|
||||
SessionID: liveSession,
|
||||
Cwd: "/tmp/live",
|
||||
ProcStart: liveStart,
|
||||
Status: "busy",
|
||||
})
|
||||
|
||||
// A goal for the live session: must be EXCLUDED (already in fleet).
|
||||
liveGoal := filepath.Join(goalsDir, liveSession+".json")
|
||||
writeJSON(t, liveGoal, goalFile{Goal: "trabajo en curso", Emojis: "🔥", Rename: "vivo"})
|
||||
touch(t, liveGoal, 5000)
|
||||
|
||||
// A DEAD session with a goal: must be INCLUDED. No sessions/ entry,
|
||||
// so it can never be live.
|
||||
const deadOld = "22222222-aaaa-bbbb-cccc-000000000002"
|
||||
oldGoal := filepath.Join(goalsDir, deadOld+".json")
|
||||
writeJSON(t, oldGoal, goalFile{Goal: "objetivo antiguo", Emojis: "🛠️", Rename: "viejo"})
|
||||
touch(t, oldGoal, 1000)
|
||||
|
||||
// Another DEAD session with a goal, more recent: must come FIRST.
|
||||
const deadNew = "33333333-aaaa-bbbb-cccc-000000000003"
|
||||
newGoal := filepath.Join(goalsDir, deadNew+".json")
|
||||
writeJSON(t, newGoal, goalFile{Goal: "objetivo reciente", Rename: "nuevo"})
|
||||
touch(t, newGoal, 4000)
|
||||
|
||||
// A DEAD session WITHOUT a goal string: must be OMITTED.
|
||||
const deadEmpty = "44444444-aaaa-bbbb-cccc-000000000004"
|
||||
emptyGoal := filepath.Join(goalsDir, deadEmpty+".json")
|
||||
writeJSON(t, emptyGoal, goalFile{Goal: " ", Emojis: "💤"})
|
||||
touch(t, emptyGoal, 6000)
|
||||
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d resumable, want 2: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// Order by LastActive desc: deadNew (4000) before deadOld (1000).
|
||||
if got[0].SessionID != deadNew {
|
||||
t.Errorf("got[0].SessionID = %q, want %q", got[0].SessionID, deadNew)
|
||||
}
|
||||
if got[1].SessionID != deadOld {
|
||||
t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, deadOld)
|
||||
}
|
||||
|
||||
// Live session must not appear.
|
||||
for _, r := range got {
|
||||
if r.SessionID == liveSession {
|
||||
t.Errorf("live session %q must be excluded", liveSession)
|
||||
}
|
||||
if r.SessionID == deadEmpty {
|
||||
t.Errorf("session without goal %q must be omitted", deadEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// Field mapping for the most-recent record.
|
||||
if got[0].Goal != "objetivo reciente" {
|
||||
t.Errorf("got[0].Goal = %q", got[0].Goal)
|
||||
}
|
||||
if got[0].Name != "nuevo" {
|
||||
t.Errorf("got[0].Name = %q, want \"nuevo\"", got[0].Name)
|
||||
}
|
||||
if got[0].LastActive != 4000 {
|
||||
t.Errorf("got[0].LastActive = %d, want 4000", got[0].LastActive)
|
||||
}
|
||||
if got[1].Emojis != "🛠️" {
|
||||
t.Errorf("got[1].Emojis = %q", got[1].Emojis)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dir de goals inexistente retorna slice vacio sin error", func(t *testing.T) {
|
||||
dir := t.TempDir() // no goals/ subdir
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d, want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cap a 40 resultados mas recientes", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
goalsDir := filepath.Join(dir, "goals")
|
||||
// 50 dead sessions with goals, mtimes 1..50.
|
||||
for i := 1; i <= 50; i++ {
|
||||
id := uuidLike(i)
|
||||
p := filepath.Join(goalsDir, id+".json")
|
||||
writeJSON(t, p, goalFile{Goal: "objetivo", Rename: id})
|
||||
touch(t, p, int64(i))
|
||||
}
|
||||
got, err := ListResumableClaudesFrom(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListResumableClaudesFrom: %v", err)
|
||||
}
|
||||
if len(got) != 40 {
|
||||
t.Fatalf("got %d, want 40 (capped)", len(got))
|
||||
}
|
||||
// Most recent first: LastActive should be 50 then descending.
|
||||
if got[0].LastActive != 50 {
|
||||
t.Errorf("got[0].LastActive = %d, want 50", got[0].LastActive)
|
||||
}
|
||||
if got[39].LastActive != 11 {
|
||||
t.Errorf("got[39].LastActive = %d, want 11", got[39].LastActive)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// uuidLike builds a deterministic, unique filename stem for index i.
|
||||
func uuidLike(i int) string {
|
||||
const hex = "0123456789abcdef"
|
||||
b := []byte("00000000-0000-0000-0000-000000000000")
|
||||
// Fill the last 3 chars with i (i <= 50 fits in 2 hex digits, keep simple).
|
||||
b[len(b)-1] = hex[i%16]
|
||||
b[len(b)-2] = hex[(i/16)%16]
|
||||
b[len(b)-3] = hex[(i/256)%16]
|
||||
return string(b)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: tmux_map_claude_panes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxMapClaudePanes(socket string) (map[int]string, error)"
|
||||
description: "Devuelve un mapa claudePID -> window_id de todos los panes de un socket tmux aislado (tmux -L <socket>) cuyo proceso de pane (o un descendiente directo) sea un proceso `claude`. Lee /proc para decidir si cada #{pane_pid} es o tiene como hijo un comm == 'claude'. Permite a la TUI fleetview saber que Claude de su lista ya vive en la sesion fleet (y por tanto es conmutable) y en que window. Capa de control tmux de fleetview."
|
||||
tags: [claude-fleet, infra, tmux, claude, proc, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En fleetview es 'fleet'. Escanea TODOS los panes del servidor de ese socket (list-panes -a)."
|
||||
output: "map[int]string con clave = PID del proceso claude encontrado bajo cada pane y valor = window_id (@N) de ese pane. Panes sin claude (ni pane_pid ni hijo directo con comm 'claude') se omiten. Mapa vacio (sin error) si ningun pane corre claude. Error si socket viene vacio o si `tmux list-panes -a` falla."
|
||||
tested: true
|
||||
tests: ["TestTmuxMapClaudePanesNoClaude", "TestTmuxMapClaudePanesEmptySocket", "TestProcCommSelf", "TestFindClaudePIDDetectsChild"]
|
||||
test_file_path: "functions/infra/tmux_map_claude_panes_test.go"
|
||||
file_path: "functions/infra/tmux_map_claude_panes.go"
|
||||
notes: "Build tag //go:build !windows (depende de /proc). Comparte runTmux con tmux_new_claude_window y tmux_swap_window_into_console (mismo paquete infra). Deteccion claude: lee /proc/<pid>/comm; si no es 'claude', recorre hijos directos. Hijos directos via /proc/<pid>/task/<pid>/children (rapido, requiere CONFIG_PROC_CHILDREN); fallback a escanear /proc/*/stat por PPID (campo 4, parseando el comm entre parentesis tomando lo que hay tras el ULTIMO ')'). En produccion cada pane corre `exec claude`, asi que pane_pid == claude PID y basta el primer comm; el barrido de hijos es robustez para shells intermedios."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Que Claude ya vive en la sesion fleet (socket aislado 'fleet') y donde.
|
||||
byPID, err := infra.TmuxMapClaudePanes("fleet")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for claudePID, windowID := range byPID {
|
||||
fmt.Printf("claude pid=%d -> window %s\n", claudePID, windowID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando la TUI fleetview refresca su lista de Claudes y necesita marcar cuales ya estan dentro de la sesion `fleet` (conmutables con `tmux_swap_window_into_console`) y en que window. Cruza el PID de cada entrada de `list_claude_fleet` contra este mapa: si el PID esta, el Claude es swap-able y el valor es su `window_id`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Mapea por PID de claude, no por pane_pid: si el pane corre un shell que lanzo claude como hijo, la clave es el PID del hijo claude.
|
||||
- Solo busca hijos DIRECTOS (un nivel). En produccion fleetview usa `exec claude`, asi que pane_pid == claude PID y el caso comun no necesita el barrido.
|
||||
- Depende de `/proc` (Linux): build tag `//go:build !windows`. En kernels sin `CONFIG_PROC_CHILDREN` cae a escanear `/proc/*/stat` por PPID, mas lento pero equivalente.
|
||||
- Lee `comm` (truncado a 15 chars por el kernel); `claude` cabe entero, sin riesgo de truncado.
|
||||
- Panes sin claude se omiten silenciosamente: un mapa vacio significa "ningun Claude vivo en este socket", no es error.
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`), escaneando todos sus panes con `list-panes -a`.
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTmuxMapClaudePanesNoClaude verifica que, sobre un servidor tmux aislado
|
||||
// cuyos panes solo corren `cat` (no claude), el mapa devuelto esta vacio: ningun
|
||||
// pane es ni tiene como hijo un proceso `claude`. Tambien valida que el comando
|
||||
// list-panes -a se ejecuta sin error sobre el socket aislado.
|
||||
func TestTmuxMapClaudePanesNoClaude(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
newCatWindow(t, socket, session)
|
||||
newCatWindow(t, socket, session)
|
||||
|
||||
m, err := TmuxMapClaudePanes(socket)
|
||||
if err != nil {
|
||||
t.Fatalf("TmuxMapClaudePanes: %v", err)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Errorf("ningun pane corre claude, el mapa deberia estar vacio, tiene %d: %v", len(m), m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxMapClaudePanesEmptySocket(t *testing.T) {
|
||||
if _, err := TmuxMapClaudePanes(""); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcCommSelf valida procComm contra el propio proceso de test: comm debe
|
||||
// coincidir con el de /proc/self/comm (el binario de test, no "claude").
|
||||
func TestProcCommSelf(t *testing.T) {
|
||||
self := os.Getpid()
|
||||
got := procComm(self)
|
||||
if got == "" {
|
||||
t.Fatalf("procComm(%d) devolvio vacio", self)
|
||||
}
|
||||
want := strings.TrimSpace(readSelfComm(t))
|
||||
if got != want {
|
||||
t.Errorf("procComm(%d) = %q, /proc/self/comm = %q", self, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readSelfComm(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("/proc/self/comm")
|
||||
if err != nil {
|
||||
t.Fatalf("read /proc/self/comm: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// TestFindClaudePIDDetectsChild ejercita el mecanismo "¿este pid o hijo es
|
||||
// claude?" SIN claude real: lanza un proceso hijo cuyo comm sea verificable y
|
||||
// comprueba que (a) findClaudePID(propio pid) no lo confunde con claude, y (b)
|
||||
// procChildren detecta al hijo lanzado. Testear con un proceso `claude` real es
|
||||
// inviable en CI; este test valida el helper de deteccion con un comm conocido.
|
||||
func TestFindClaudePIDDetectsChild(t *testing.T) {
|
||||
// (a) El proceso de test NO es claude: findClaudePID no debe reportarlo.
|
||||
if _, ok := findClaudePID(os.Getpid()); ok {
|
||||
// Solo seria true si el binario de test se llamara "claude" (no es el caso).
|
||||
t.Errorf("findClaudePID(self) reporto claude para un proceso que no lo es")
|
||||
}
|
||||
|
||||
// (b) Lanzamos un hijo `sleep` (comm conocido "sleep") y verificamos que
|
||||
// procChildren lo detecta como descendiente directo. Esto valida el
|
||||
// mecanismo de barrido de hijos que findClaudePID usa internamente para
|
||||
// localizar un comm objetivo (en produccion: "claude").
|
||||
cmd := exec.Command("sleep", "3")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("no se pudo lanzar sleep: %v", err)
|
||||
}
|
||||
childPID := cmd.Process.Pid
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
|
||||
|
||||
kids := procChildren(os.Getpid())
|
||||
found := false
|
||||
for _, k := range kids {
|
||||
if k == childPID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("procChildren(self) no incluyo al hijo %d; kids=%v", childPID, kids)
|
||||
}
|
||||
|
||||
// Y el comm del hijo debe ser "sleep", confirmando el camino que findClaudePID
|
||||
// usa para comparar contra "claude".
|
||||
if comm := procComm(childPID); comm != "sleep" {
|
||||
t.Errorf("procComm(%d) = %q, esperado \"sleep\"", childPID, comm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxNewClaudeWindow crea una window nueva en <session> del socket <socket>
|
||||
// que corre `claude --dangerously-skip-permissions` en <cwd>. Acepta argumentos
|
||||
// extra opcionales que se anaden al comando (ej. "--resume", "<sessionId>" para
|
||||
// reabrir una conversacion). Devuelve el window_id (ej "@7"). No cambia el foco
|
||||
// (-d). Opera SIEMPRE sobre el socket aislado pasado como parametro
|
||||
// (tmux -L <socket>), nunca sobre el servidor tmux por defecto del usuario.
|
||||
func TmuxNewClaudeWindow(socket, session, cwd string, extraArgs ...string) (string, error) {
|
||||
if socket == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: socket vacio")
|
||||
}
|
||||
if session == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: session vacia")
|
||||
}
|
||||
if cwd == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: cwd vacio")
|
||||
}
|
||||
|
||||
// El comando del pane: claude reemplaza al shell, asi que #{pane_pid} sera el
|
||||
// PID de claude. Se anaden los argumentos extra (ej. --resume <id>).
|
||||
command := "claude --dangerously-skip-permissions"
|
||||
if len(extraArgs) > 0 {
|
||||
command += " " + strings.Join(extraArgs, " ")
|
||||
}
|
||||
|
||||
// -d: no cambia el foco. -P -F '#{window_id}': imprime el id de la window
|
||||
// creada. -t <session>: la crea en esa sesion. -c <cwd>: working dir del pane.
|
||||
out, stderr, err := runTmux(socket,
|
||||
"new-window", "-d", "-P", "-F", "#{window_id}",
|
||||
"-t", session, "-c", cwd,
|
||||
command,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: new-window en %q: %w (%s)", session, err, stderr)
|
||||
}
|
||||
|
||||
windowID := strings.TrimSpace(out)
|
||||
if windowID == "" {
|
||||
return "", fmt.Errorf("tmux_new_claude_window: new-window no devolvio window_id (stderr=%q)", stderr)
|
||||
}
|
||||
return windowID, nil
|
||||
}
|
||||
|
||||
// runTmux ejecuta `tmux -L <socket> <args...>` y devuelve stdout, stderr y el
|
||||
// error de ejecucion. Helper comun a la capa de control tmux de fleetview.
|
||||
func runTmux(socket string, args ...string) (stdout, stderr string, err error) {
|
||||
full := append([]string{"-L", socket}, args...)
|
||||
cmd := exec.Command("tmux", full...)
|
||||
var outBuf, errBuf strings.Builder
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err = cmd.Run()
|
||||
return outBuf.String(), errBuf.String(), err
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: tmux_new_claude_window
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxNewClaudeWindow(socket, session, cwd string) (string, error)"
|
||||
description: "Crea una window detached nueva en una sesion tmux de un socket aislado (tmux -L <socket>) que corre `claude --dangerously-skip-permissions` en el cwd dado, y devuelve su window_id (ej @7). No cambia el foco. Capa de control tmux de la app TUI fleetview para arrancar un Claude nuevo dentro de la sesion fleet. Como el pane corre claude via exec, el #{pane_pid} del pane resultante es el PID del proceso claude."
|
||||
tags: [claude-fleet, infra, tmux, claude, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (se invoca tmux -L <socket>). En fleetview es 'fleet'. Nunca opera sobre el servidor tmux por defecto del usuario."
|
||||
- name: "session"
|
||||
desc: "Nombre de la sesion tmux donde crear la window (ej 'fleet'). Debe existir."
|
||||
- name: "cwd"
|
||||
desc: "Working directory del nuevo pane/Claude (-c). Ruta absoluta del proyecto donde arrancar el Claude."
|
||||
output: "window_id de la window creada (string con la forma @N, ej '@7'), tal cual lo imprime `tmux new-window -P -F '#{window_id}'`. Error si socket/session/cwd vienen vacios o si tmux falla (sesion inexistente, socket no accesible)."
|
||||
tested: true
|
||||
tests: ["TestTmuxNewClaudeWindow", "TestTmuxNewClaudeWindowEmptyArgs"]
|
||||
test_file_path: "functions/infra/tmux_new_claude_window_test.go"
|
||||
file_path: "functions/infra/tmux_new_claude_window.go"
|
||||
notes: "Build tag //go:build !windows (capa tmux de fleetview, no portable a Windows). Comparte el helper runTmux con tmux_swap_window_into_console y tmux_map_claude_panes (mismo paquete infra). El comando que corre el pane es literalmente 'claude --dangerously-skip-permissions'; tmux lo arranca via su shell pero claude reemplaza al proceso, asi que pane_pid == claude PID."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Arranca un Claude nuevo en /home/enmanuel/fn_registry dentro de la
|
||||
// sesion 'fleet' del socket aislado 'fleet'. No roba el foco.
|
||||
windowID, err := infra.TmuxNewClaudeWindow("fleet", "fleet", "/home/enmanuel/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Claude nuevo en window", windowID) // ej: @7
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando la TUI fleetview necesita arrancar un Claude nuevo dentro de la sesion `fleet` sin sacar al usuario de la consola actual. El Claude nace parkeado en su propia window (detached); luego `TmuxSwapWindowIntoConsole` lo trae a la derecha de la TUI cuando el usuario lo selecciona.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`). Nunca toca el servidor tmux por defecto del usuario.
|
||||
- La sesion `session` debe existir antes de llamar; la funcion crea la window, no la sesion.
|
||||
- Devuelve el `window_id` (`@N`), no el `window_index`. El swap posterior usa este id.
|
||||
- `-d` garantiza que no cambia el foco: el Claude nuevo queda parkeado, no se muestra solo.
|
||||
- Build tag `//go:build !windows`: no compila ni corre en Windows.
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tmuxAvailable reports whether the tmux binary is present. Tests skip when it
|
||||
// is not (CI without tmux).
|
||||
func tmuxAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
t.Skipf("tmux no disponible en PATH: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// isolatedSocket returns a per-test isolated tmux socket name and registers a
|
||||
// cleanup that kills its server. All commands in a test run against
|
||||
// `tmux -L <socket> ...`, never the user's default server.
|
||||
func isolatedSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
socket := fmt.Sprintf("fleettest_%d_%d", os.Getpid(), time.Now().UnixNano())
|
||||
t.Cleanup(func() {
|
||||
// best-effort: el server puede no existir si el test fallo antes de crearlo
|
||||
_ = exec.Command("tmux", "-L", socket, "kill-server").Run()
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
// startConsoleSession crea una sesion <session> con una window "console" cuyo
|
||||
// pane 0 corre `cat` (simula la TUI fleetview, un proceso que no termina).
|
||||
func startConsoleSession(t *testing.T, socket, session string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("tmux", "-L", socket,
|
||||
"new-session", "-d", "-s", session, "-n", "console", "cat")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("new-session: %v (%s)", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxNewClaudeWindow(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
// El comando real ("claude ...") no esta disponible en el test, pero
|
||||
// new-window devuelve el window_id ANTES de que el comando pueda fallar:
|
||||
// tmux crea la window y reporta su id sincronamente. Validamos que el id
|
||||
// venga con la forma esperada (@N) y no vacio.
|
||||
windowID, err := TmuxNewClaudeWindow(socket, session, cwd)
|
||||
if err != nil {
|
||||
t.Fatalf("TmuxNewClaudeWindow: %v", err)
|
||||
}
|
||||
if windowID == "" {
|
||||
t.Fatal("window_id vacio")
|
||||
}
|
||||
if !strings.HasPrefix(windowID, "@") {
|
||||
t.Errorf("window_id %q no tiene la forma esperada @N", windowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxNewClaudeWindowEmptyArgs(t *testing.T) {
|
||||
if _, err := TmuxNewClaudeWindow("", "fleet", "/tmp"); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
if _, err := TmuxNewClaudeWindow("sock", "", "/tmp"); err == nil {
|
||||
t.Error("session vacia deberia dar error")
|
||||
}
|
||||
if _, err := TmuxNewClaudeWindow("sock", "fleet", ""); err == nil {
|
||||
t.Error("cwd vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//go:build !windows
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TmuxSwapWindowIntoConsole trae el primer pane de <windowID> al pane derecho
|
||||
// de la window "console" de <session> (al lado del pane 0 = la TUI), parkeando
|
||||
// el Claude que estuviera a la derecha en su propia window (detached, sin robar
|
||||
// foco), y re-fija el ancho del pane 0 (TUI) a 40 columnas.
|
||||
//
|
||||
// Contrato de la window console:
|
||||
// - pane indice 0 = siempre la TUI fleetview (no se toca).
|
||||
// - cualquier otro pane en console = el Claude activo (puede no haber ninguno).
|
||||
//
|
||||
// Idempotente: si <windowID> ES ya la window console, no hace nada. Si el Claude
|
||||
// objetivo ya esta en console, tampoco rompe nada (el break-pane se aplica al
|
||||
// pane derecho que estuviera; si no lo hay, se salta). Opera SIEMPRE sobre el
|
||||
// socket aislado pasado como parametro (tmux -L <socket>).
|
||||
func TmuxSwapWindowIntoConsole(socket, session, windowID string) error {
|
||||
if socket == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: socket vacio")
|
||||
}
|
||||
if session == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: session vacia")
|
||||
}
|
||||
if windowID == "" {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: windowID vacio")
|
||||
}
|
||||
|
||||
consoleTarget := session + ":console"
|
||||
|
||||
// Capturar el ancho ACTUAL del pane 0 (la TUI) antes de tocar nada, para
|
||||
// preservarlo tras el break/join (que redistribuyen el espacio). Así el ancho
|
||||
// del sidebar lo decide quien creó la sesión (launch_kittyclaude), no un valor
|
||||
// fijo aquí.
|
||||
width := tmuxPane0Width(socket, session)
|
||||
|
||||
// Caso borde: si windowID ya ES la window console, no hay nada que hacer.
|
||||
// Resolvemos el window_id real de console y lo comparamos con el pedido.
|
||||
consoleID, err := tmuxConsoleWindowID(socket, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consoleID == windowID {
|
||||
// El objetivo ya es console. Solo re-fijamos el ancho de la TUI.
|
||||
return tmuxResizeConsoleTUI(socket, session, width)
|
||||
}
|
||||
|
||||
// 1. Localiza el pane derecho actual de console (cualquier pane con indice != 0).
|
||||
out, stderr, err := runTmux(socket, "list-panes", "-t", consoleTarget, "-F", "#{pane_index} #{pane_id}")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: list-panes de %q: %w (%s)", consoleTarget, err, stderr)
|
||||
}
|
||||
|
||||
var rightPaneID 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
|
||||
}
|
||||
if fields[0] != "0" {
|
||||
rightPaneID = fields[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si existe un pane no-0 en console, sacarlo a su propia window (parking),
|
||||
// detached y sin cambiar foco.
|
||||
if rightPaneID != "" {
|
||||
if _, stderr, err := runTmux(socket, "break-pane", "-d", "-s", rightPaneID); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: break-pane de %q: %w (%s)", rightPaneID, err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Traer el primer pane de windowID a la derecha de la TUI (-h = split
|
||||
// horizontal, lado a lado). join-pane requiere que origen y destino sean
|
||||
// windows distintas (ya garantizado: consoleID != windowID arriba).
|
||||
src := windowID + ".0"
|
||||
dst := consoleTarget + ".0"
|
||||
if _, stderr, err := runTmux(socket, "join-pane", "-h", "-s", src, "-t", dst); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: join-pane %q -> %q: %w (%s)", src, dst, err, stderr)
|
||||
}
|
||||
|
||||
// 4. Re-fijar el ancho del pane 0 (TUI) al que tenia antes del swap.
|
||||
return tmuxResizeConsoleTUI(socket, session, width)
|
||||
}
|
||||
|
||||
// tmuxConsoleWindowID resuelve el window_id (ej "@3") de la window llamada
|
||||
// "console" en <session>.
|
||||
func tmuxConsoleWindowID(socket, session string) (string, error) {
|
||||
out, stderr, err := runTmux(socket, "list-windows", "-t", session, "-F", "#{window_id} #{window_name}")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux_swap_window_into_console: list-windows de %q: %w (%s)", session, err, stderr)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "console" {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tmux_swap_window_into_console: window 'console' no encontrada en %q", session)
|
||||
}
|
||||
|
||||
// tmuxPane0Width devuelve el ancho a preservar para el pane 0 (la TUI). Solo
|
||||
// tiene sentido leer el ancho actual si console ya tiene >1 pane (TUI + Claude);
|
||||
// con un único pane, el pane 0 es full-width y no representa el sidebar, así que
|
||||
// se usa el default (47 columnas).
|
||||
func tmuxPane0Width(socket, session string) int {
|
||||
const def = 47
|
||||
out, _, err := runTmux(socket, "list-panes", "-t", session+":console", "-F", "#{pane_index} #{pane_width}")
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) <= 1 {
|
||||
return def
|
||||
}
|
||||
for _, l := range lines {
|
||||
f := strings.Fields(strings.TrimSpace(l))
|
||||
if len(f) >= 2 && f[0] == "0" {
|
||||
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// tmuxResizeConsoleTUI fija el ancho del pane 0 de console a width columnas.
|
||||
func tmuxResizeConsoleTUI(socket, session string, width int) error {
|
||||
target := session + ":console.0"
|
||||
if _, stderr, err := runTmux(socket, "resize-pane", "-t", target, "-x", strconv.Itoa(width)); err != nil {
|
||||
return fmt.Errorf("tmux_swap_window_into_console: resize-pane de %q a %d col: %w (%s)", target, width, err, stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: tmux_swap_window_into_console
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func TmuxSwapWindowIntoConsole(socket, session, windowID string) error"
|
||||
description: "Conmuta que Claude esta a la derecha de la TUI fleetview en una sesion tmux de un socket aislado (tmux -L <socket>). Trae el primer pane de <windowID> al pane derecho de la window 'console' (al lado del pane 0 = la TUI), parkea en su propia window el Claude que estuviera a la derecha (detached, sin robar foco) y re-fija el ancho del pane 0 a 40 columnas. Idempotente: si el objetivo ya es la window console no hace nada. Capa de control tmux de la app TUI fleetview."
|
||||
tags: [claude-fleet, infra, tmux, claude, fleet, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "socket"
|
||||
desc: "Nombre del socket tmux aislado (tmux -L <socket>). En fleetview es 'fleet'. Nunca opera sobre el servidor tmux por defecto."
|
||||
- name: "session"
|
||||
desc: "Sesion tmux que contiene la window 'console' (ej 'fleet'). El pane 0 de console es la TUI; el resto, el Claude activo."
|
||||
- name: "windowID"
|
||||
desc: "window_id (@N) de la window cuyo primer pane se quiere traer a la derecha de la TUI. Tipicamente el devuelto por tmux_new_claude_window o por tmux_map_claude_panes."
|
||||
output: "nil en exito. Error si socket/session/windowID vienen vacios, si la window 'console' no existe en la sesion, o si alguno de los comandos tmux (list-panes, break-pane, join-pane, resize-pane) falla. El estado final de console: pane 0 = TUI (40 col) + pane derecho = el Claude de windowID."
|
||||
tested: true
|
||||
tests: ["TestTmuxSwapWindowIntoConsole", "TestTmuxSwapWindowIntoConsoleParksPrevious", "TestTmuxSwapWindowIntoConsoleEmptyArgs"]
|
||||
test_file_path: "functions/infra/tmux_swap_window_into_console_test.go"
|
||||
file_path: "functions/infra/tmux_swap_window_into_console.go"
|
||||
notes: "Build tag //go:build !windows. Comparte runTmux con tmux_new_claude_window y tmux_map_claude_panes (mismo paquete infra). Secuencia interna: (1) list-panes de console y localiza el pane no-0 actual; (2) break-pane -d de ese pane si existe (parking); (3) join-pane -h del primer pane de windowID a console.0 (lado a lado); (4) resize-pane -x 40 del pane 0. Caso borde: si windowID ya ES la window console, solo re-aplica el resize. break-pane requiere que la window destino sea distinta del origen, garantizado por la comprobacion consoleID != windowID."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "fn-registry/functions/infra"
|
||||
|
||||
func main() {
|
||||
// El usuario selecciona en fleetview el Claude que vive en la window @7.
|
||||
// Lo trae a la derecha de la TUI (pane 1 de console), parkeando el que
|
||||
// estuviera ahi. La TUI (pane 0) queda re-fijada a 40 columnas.
|
||||
if err := infra.TmuxSwapWindowIntoConsole("fleet", "fleet", "@7"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cada vez que el usuario conmuta en fleetview que Claude quiere ver a la derecha. Llamala con el `window_id` del Claude destino (de `tmux_map_claude_panes` para los ya vivos en la sesion, o de `tmux_new_claude_window` para uno recien arrancado). Encadena de forma natural tras `tmux_new_claude_window` para mostrar inmediatamente el Claude nuevo.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Idempotente: si el Claude objetivo ya es la window console, solo re-aplica el ancho de 40 col; no rompe nada.
|
||||
- El pane indice 0 de console es SIEMPRE la TUI y nunca se mueve ni se parkea: la funcion solo toca el pane derecho (indice != 0).
|
||||
- `join-pane` exige que la window origen sea distinta de console; la funcion lo comprueba (consoleID != windowID) y si coinciden no hace el join.
|
||||
- `break-pane -d` saca el Claude anterior a su propia window detached: sigue vivo y parkeado, no se mata.
|
||||
- El ancho de 40 col se re-fija SIEMPRE al final (incluso en el caso borde) para que la TUI no se reduzca tras el reflow del split.
|
||||
- Opera SIEMPRE sobre el socket aislado (`tmux -L <socket>`). Build tag `//go:build !windows`.
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build !windows && linux
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newCatWindow crea una window detached en <session> que corre `cat` (un
|
||||
// proceso persistente que simula un claude parkeado) y devuelve su window_id.
|
||||
func newCatWindow(t *testing.T, socket, session string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", session, "cat").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("new-window cat: %v (%s)", err, out)
|
||||
}
|
||||
id := strings.TrimSpace(string(out))
|
||||
if id == "" {
|
||||
t.Fatal("new-window cat no devolvio window_id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// consolePanes devuelve las lineas "pane_index pane_id width" de la window
|
||||
// console de <session>.
|
||||
func consolePanes(t *testing.T, socket, session string) []string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"list-panes", "-t", session+":console",
|
||||
"-F", "#{pane_index} #{pane_id} #{pane_width}").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("list-panes console: %v (%s)", err, out)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsole(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
// Una window aparte con `cat` simula un Claude parkeado conmutable.
|
||||
claudeWin := newCatWindow(t, socket, session)
|
||||
|
||||
// Estado inicial: console tiene un solo pane (la TUI, indice 0).
|
||||
if got := len(consolePanes(t, socket, session)); got != 1 {
|
||||
t.Fatalf("console deberia empezar con 1 pane, tiene %d", got)
|
||||
}
|
||||
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, claudeWin); err != nil {
|
||||
t.Fatalf("TmuxSwapWindowIntoConsole: %v", err)
|
||||
}
|
||||
|
||||
// Tras el swap: console tiene 2 panes y el pane 0 mide 47 columnas (default
|
||||
// del sidebar, ya que la console arrancó con un solo pane full-width).
|
||||
panes := consolePanes(t, socket, session)
|
||||
if len(panes) != 2 {
|
||||
t.Fatalf("console deberia tener 2 panes tras swap, tiene %d: %v", len(panes), panes)
|
||||
}
|
||||
var width0 int
|
||||
found0 := false
|
||||
for _, line := range panes {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 && f[0] == "0" {
|
||||
found0 = true
|
||||
w, err := strconv.Atoi(f[2])
|
||||
if err != nil {
|
||||
t.Fatalf("ancho del pane 0 no numerico: %q", f[2])
|
||||
}
|
||||
width0 = w
|
||||
}
|
||||
}
|
||||
if !found0 {
|
||||
t.Fatal("no se encontro el pane 0 en console")
|
||||
}
|
||||
if width0 != 47 {
|
||||
t.Errorf("ancho del pane 0 = %d, esperado 47", width0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsoleParksPrevious(t *testing.T) {
|
||||
tmuxAvailable(t)
|
||||
socket := isolatedSocket(t)
|
||||
session := "fleet"
|
||||
startConsoleSession(t, socket, session)
|
||||
|
||||
winA := newCatWindow(t, socket, session)
|
||||
winB := newCatWindow(t, socket, session)
|
||||
|
||||
// Trae A a console.
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, winA); err != nil {
|
||||
t.Fatalf("swap A: %v", err)
|
||||
}
|
||||
if got := len(consolePanes(t, socket, session)); got != 2 {
|
||||
t.Fatalf("tras swap A console deberia tener 2 panes, tiene %d", got)
|
||||
}
|
||||
|
||||
// Trae B: A se parkea fuera, console vuelve a 2 panes (TUI + B).
|
||||
if err := TmuxSwapWindowIntoConsole(socket, session, winB); err != nil {
|
||||
t.Fatalf("swap B: %v", err)
|
||||
}
|
||||
if got := len(consolePanes(t, socket, session)); got != 2 {
|
||||
t.Fatalf("tras swap B console deberia tener 2 panes, tiene %d", got)
|
||||
}
|
||||
|
||||
// El Claude A parkeado debe seguir vivo en alguna window de la sesion.
|
||||
out, err := exec.Command("tmux", "-L", socket,
|
||||
"list-windows", "-t", session, "-F", "#{window_id}").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("list-windows: %v (%s)", err, out)
|
||||
}
|
||||
winCount := 0
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
winCount++
|
||||
}
|
||||
}
|
||||
// Esperadas: console + (window de A parkeada). winB se consumio al unir su
|
||||
// pane a console (la window vacia se cierra). winA: una window de parking.
|
||||
if winCount < 2 {
|
||||
t.Errorf("se esperaban >=2 windows (console + A parkeado), hay %d", winCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxSwapWindowIntoConsoleEmptyArgs(t *testing.T) {
|
||||
if err := TmuxSwapWindowIntoConsole("", "fleet", "@1"); err == nil {
|
||||
t.Error("socket vacio deberia dar error")
|
||||
}
|
||||
if err := TmuxSwapWindowIntoConsole("sock", "", "@1"); err == nil {
|
||||
t.Error("session vacia deberia dar error")
|
||||
}
|
||||
if err := TmuxSwapWindowIntoConsole("sock", "fleet", ""); err == nil {
|
||||
t.Error("windowID vacio deberia dar error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user