#!/usr/bin/env bash # launch_fleetclaude — Entrypoint MVP de FleetView. # # Abre UNA ventana kitty corriendo una sesion tmux de dos panes: # - pane izquierdo: la TUI 'fleetview' (la flota de Claudes centralizada). # - pane derecho: 'claude --dangerously-skip-permissions'. # # Objetivo: dejar de tener N ventanas kitty dispersas y centralizar el control # de los Claudes en una sola ventana. # # Funcion IMPURA: lanza procesos (tmux + kitty) con efectos secundarios. # - Crea/reusa una sesion tmux detached llamada (idempotente). # - Lanza una ventana kitty desacoplada del shell padre (setsid) para que # sobreviva al cierre de la terminal que la invoco. # - No toca atajos de teclado ni kitty.conf. set -euo pipefail IFS=$' \t\n' launch_fleetclaude() { local cwd="" local bin="" local session="fleet" local cols=52 local explicit_session=0 # 1 si el usuario pasó --session a mano local reuse=0 # 1 si el usuario pidió --reuse (reattach al perfil principal) local T="" # socket tmux aislado; se fija al resolver el perfil # ----------------------------------------------------------------------- # Parseo de argumentos # ----------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --cwd) shift cwd="${1:-}" ;; --bin) shift bin="${1:-}" ;; --session) shift session="${1:-}" explicit_session=1 ;; --reuse) reuse=1 ;; --cols) shift cols="${1:-40}" ;; -h|--help) cat <<'USAGE' Uso: launch_fleetclaude [opciones] Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la izquierda y 'claude --dangerously-skip-permissions' a la derecha. Cada PERFIL de FleetView es un socket+sesion tmux aislados (su propia flota de Claudes). Sin --session ni --reuse, cada invocacion abre un perfil NUEVO: usa el primer nombre libre de la secuencia fleet, fleet2, fleet3, ... Asi puedes tener varias FleetView abiertas a la vez, cada una con su flota independiente. Opciones: --cwd Directorio de trabajo de los panes. Default: raiz del repo fn_registry (derivada dinamicamente). --bin Ruta al binario de la TUI fleetview. Default: /apps/fleetview/fleetview --session Fija el perfil (socket+sesion) por nombre exacto; reutiliza el existente si ya esta vivo. Sin esta opcion, perfil auto. --reuse Reattach al perfil principal 'fleet' en vez de abrir uno nuevo (vuelve al comportamiento idempotente clasico). --cols Ancho (columnas) del pane izquierdo. Default: 40. -h, --help Muestra esta ayuda. Ejemplos: launch_fleetclaude # perfil nuevo (fleet, luego fleet2, ...) launch_fleetclaude --reuse # reattach a la flota principal 'fleet' launch_fleetclaude --session trabajo # perfil con nombre fijo 'trabajo' launch_fleetclaude --cwd ~/fn_registry --cols 50 USAGE return 0 ;; *) echo "launch_fleetclaude: opcion desconocida: '$1' (usa -h)" >&2 return 2 ;; esac shift done # ----------------------------------------------------------------------- # Derivar la raiz del repo fn_registry dinamicamente (NO hardcodear paths # de usuario). Estrategia: subir desde la ubicacion del script con # 'git rev-parse --show-toplevel'; fallbacks razonables si no aplica. # ----------------------------------------------------------------------- local script_dir repo_root="" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # El script vive en /bash/functions/infra/, asi que la raiz son 3 # niveles arriba; pero preferimos git para robustez. repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$repo_root" ]]; then # Fallback 1: navegacion relativa desde la ubicacion del script. repo_root="$(cd "$script_dir/../../.." 2>/dev/null && pwd || true)" fi if [[ -z "$repo_root" ]]; then # Fallback 2: variable de entorno del registry o el cwd actual. repo_root="${FN_REGISTRY_ROOT:-$PWD}" fi # Defaults derivados de la raiz del repo. [[ -z "$cwd" ]] && cwd="$repo_root" [[ -z "$bin" ]] && bin="$repo_root/apps/fleetview/fleetview" # Validar cwd: si no existe, caer al repo_root. if [[ ! -d "$cwd" ]]; then echo "launch_fleetclaude: --cwd '$cwd' no existe; usando '$repo_root'." >&2 cwd="$repo_root" fi # ----------------------------------------------------------------------- # Comprobar herramientas necesarias. # ----------------------------------------------------------------------- if ! command -v tmux >/dev/null 2>&1; then echo "launch_fleetclaude: tmux no esta instalado." >&2 return 1 fi # ----------------------------------------------------------------------- # Resolver el PERFIL (socket+sesion tmux comparten nombre). # # - --session -> usa ese nombre exacto (reutiliza si ya vive). # - --reuse -> usa 'fleet' (el perfil principal), idempotente. # - sin nada -> perfil NUEVO: primer nombre libre de la secuencia # fleet, fleet2, fleet3, ... Asi abrir FleetView con # uno ya abierto arranca otra flota, no la reusa. # # "Libre" = no hay un server tmux con esa sesion (has-session falla). Un # perfil cerrado libera su nombre, asi que tras cerrar 'fleet' el siguiente # lanzamiento vuelve a 'fleet'. # ----------------------------------------------------------------------- if [[ "$explicit_session" -eq 0 && "$reuse" -eq 0 ]]; then local base="$session" n=1 cand while :; do if [[ "$n" -eq 1 ]]; then cand="$base"; else cand="${base}${n}"; fi if ! tmux -L "$cand" has-session -t "$cand" 2>/dev/null; then session="$cand" break fi n=$((n + 1)) done echo "launch_fleetclaude: perfil nuevo '$session'." fi # A partir de aqui el socket aislado es el del perfil resuelto. T="tmux -L $session" # Nota: kitty NO se exige aqui. La ruta interactiva (TTY) reutiliza la # terminal actual con `exec tmux attach` y no necesita kitty. Solo la # ruta sin-TTY (abrir ventana nueva con setsid kitty) lo requiere, y ahi # se comprueba justo antes de usarlo. # ----------------------------------------------------------------------- # Comando para el pane izquierdo: # - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado). # - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio). # ----------------------------------------------------------------------- # La TUI necesita saber a qué perfil pertenece: se lo pasamos por entorno # (FLEET_SOCKET/FLEET_SESSION), que main.go lee con fallback a "fleet". local envpfx envpfx="FLEET_SOCKET=$(printf '%q' "$session") FLEET_SESSION=$(printf '%q' "$session")" local left_cmd if [[ -x "$bin" ]]; then left_cmd="$envpfx exec $(printf '%q' "$bin")" else # Fallback claro: instruye como compilar la TUI y deja una shell viva. left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\"" fi # ----------------------------------------------------------------------- # Montar la sesion tmux SOLO si no existe (idempotencia). Socket aislado $T. # # Targeting por PANE ID (%0/%1), no por indice (console.0). El socket # -L fleet sigue leyendo ~/.tmux.conf; si el usuario tiene # `base-index 1` / `pane-base-index 1` (muy comun), el primer pane es el # indice 1 y cualquier referencia a console.0 falla con # "can't find pane: 0". Los pane ID son estables e inmunes al base-index. # ----------------------------------------------------------------------- local left_pane right_pane if $T has-session -t "$session" 2>/dev/null; then echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola." else echo "launch_fleetclaude: creando sesion tmux '$session' en '$cwd'." # Sesion detached con ventana 'console'. Capturamos el pane ID del pane # izquierdo (la TUI fleetview, o el fallback claro). left_pane=$($T new-session -d -s "$session" -n console -c "$cwd" -P -F '#{pane_id}') $T send-keys -t "$left_pane" "$left_cmd" C-m # pane derecho = el ORQUESTADOR de la flota: un Claude que arranca ya en # modo orquestador invocando el skill /orquestador como primer prompt. Es # el Claude con el que el humano habla; vigila la flota por su DoD. right_pane=$($T split-window -h -t "$left_pane" -c "$cwd" -P -F '#{pane_id}') $T send-keys -t "$right_pane" "exec claude --dangerously-skip-permissions '/orquestador'" C-m # Fijar el ancho del pane izquierdo en columnas. $T resize-pane -t "$left_pane" -x "$cols" # Foco inicial en el pane del orquestador (derecha). $T select-pane -t "$right_pane" # Marcar el orquestador con role=orchestrator en su goal.json para que la # TUI lo pinee arriba (estrella). El sessionId no se conoce hasta que # Claude escribe sessions/.json; mark_claude_role resuelve # PID->sessionId esperando ese archivo. En background (no bloquea el # arranque) y con sleep para que el `exec claude` reemplace al shell antes # de leer el pane_pid. Fallo = no-fatal (el orquestador no se pinea). if [[ -x "$repo_root/fn" ]]; then ( sleep 1 orch_pid=$($T display-message -p -t "$right_pane" '#{pane_pid}' 2>/dev/null || true) [[ -n "$orch_pid" ]] && "$repo_root/fn" run mark_claude_role "$orch_pid" orchestrator >/dev/null 2>&1 ) >/dev/null 2>&1 & disown 2>/dev/null || true fi # Sembrar 1 ejecutor idle: una window detached con un claude normal, # listo para recibir tarea del orquestador. Aparece en la TUI bajo el # orquestador (role executor por defecto). Hereda FLEET_SOCKET/SESSION # del server (set-environment), asi apunta a este perfil. local idle_pane idle_pane=$($T new-window -d -t "$session" -n claude -c "$cwd" -P -F '#{pane_id}') $T send-keys -t "$idle_pane" "exec claude --dangerously-skip-permissions" C-m fi # Si reutilizamos sesion (o por seguridad), derivar el pane ID de la TUI: # el primer pane de la ventana 'console' (orden por indice) es el izquierdo. if [[ -z "$left_pane" ]]; then left_pane=$($T list-panes -t "$session":console -F '#{pane_id}' 2>/dev/null | head -n1) fi # ----------------------------------------------------------------------- # Atajos globales (alt+*) en el socket aislado: redirigen la tecla al pane # de la TUI (console.0) ESTES DONDE ESTES, para controlar la flota sin salir # del pane de Claude. La TUI (fleetview) es quien interpreta Up/Down/Enter/n. # `bind -n` = tabla root (sin prefijo). Idempotente: re-set en cada lanzamiento. # ----------------------------------------------------------------------- $T bind -n M-Up send-keys -t "$left_pane" Up $T bind -n M-Down send-keys -t "$left_pane" Down $T bind -n M-Enter send-keys -t "$left_pane" Enter $T bind -n M-n send-keys -t "$left_pane" n $T bind -n M-0 send-keys -t "$left_pane" n $T bind -n M-k send-keys -t "$left_pane" k $T bind -n M-r send-keys -t "$left_pane" r $T bind -n M-u send-keys -t "$left_pane" u $T bind -n M-h send-keys -t "$left_pane" h $T bind -n M-R send-keys -t "$left_pane" R $T bind -n M-Left send-keys -t "$left_pane" Escape $T bind -n M-q send-keys -t "$left_pane" Q # Entorno del perfil en el server tmux: respawn-pane (alt+R, recompila la TUI) # y los Claude nuevos heredan FLEET_SOCKET/FLEET_SESSION para apuntar al # socket correcto aunque no sea el default "fleet". $T set-environment -g FLEET_SOCKET "$session" $T set-environment -g FLEET_SESSION "$session" # Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta. $T set -g mouse on # Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de # dejarla muerta ("dead" pane) en la sesion. $T set -g remain-on-exit off # Estetica neutra: sin el verde fosforo por defecto de tmux. Status bar gris y # bordes de pane gris tenue, iguales en activo e inactivo (separacion simple, # sin resaltado de enfoque). $T set -g status-style "bg=colour236,fg=colour250" $T set -g pane-border-style "fg=colour238" $T set -g pane-active-border-style "fg=colour240" # Mantener el ancho del sidebar (pane 0) cuando kitty redimensiona la ventana # tras el attach, o cuando se conmuta de Claude (window-linked / layout change). $T set-hook -g client-resized "resize-pane -t $left_pane -x $cols" $T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols" # ----------------------------------------------------------------------- # Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con # setsid, para que no muera al cerrar la terminal invocadora. # (Mismo patron que reboot_all_claudes para relanzar terminales.) # ----------------------------------------------------------------------- # Adjuntar la sesion: # - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el # panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la # shell). Asi `fleetclaude` no abre otra ventana: usa la actual. # - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir # una ventana kitty nueva desacoplada (setsid). No hacemos `attach` # anidado dentro de otra sesion tmux (rompe / da el warning de nesting). if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then exec tmux -L "$session" attach -t "$session" fi # Ruta ventana-nueva: necesitamos kitty para abrirla. if ! command -v kitty >/dev/null 2>&1; then echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2 echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2 return 1 fi setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" /dev/null 2>&1 & disown 2>/dev/null || true echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'." return 0 } # Permitir ejecutar el archivo directamente (no solo como funcion sourced). if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then launch_fleetclaude "$@" fi