SLOPSHOPPER

flota

Live pane listing your Claude Code sessions (local and Remote Control) and the claude-rc instances on your servers, with restart, start and URL buttons

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flota
│ ┃ Flota ✕ › fix the failing auth test and add an audit log call │ ┃ ≡ FLOTA ir:sesionesir:saludir:ayudatips 2 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ┌─ Sesion ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ┐ ⎿ Added 2 lines, removed 1 line │ ┃ │ Elige una sesion con flechas o Tab y pulsa ⏺ Bash(bun test) │ ┃ Enter (o │ ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ › /flota │ ┃ └─────────────────────────────────────────── ⎿ flota: Panel de la flota abierto; actualizando sesiones y servid │ ┃ ──────────┘ │ ┃ │ ┃ ESTADO SESION RC DONDE HACE │ ┃ │ ┃ │ ┃ ver-fueraver-apagadasactualizar │ ┃ 0 TRABAJANDO | 0 ESPERANDO | 0 SIN CONEXION │ ┃ | 0 APAGAD │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Flota
≡ FLOTA ir:sesionesir:saludir:ayudatips 26/10 17:15 ┌─ Sesion ────────────────────────────────────────────┐ │ Elige una sesion con flechas o Tab y pulsa Enter (o │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ ESTADO SESION RC DONDE HACE ver-fueraver-apagadasactualizar 0 TRABAJANDO | 0 ESPERANDO | 0 SIN CONEXION | 0 APAGAD
README

Claude Code mods

Small mods (function-hook plugins) that add live panes and buttons to Claude Code.

Versión en español abajo.

flota

A Turbo Pascal style pane, inside Claude Code, for your fleet of Claude Code sessions and the machines they run on. Three screens, switched from the menu bar at the top:

  • Principal: every session you have (this one, other local sessions and Remote Control sessions) with its state (working, waiting, closing, offline), where it runs, and how long ago it last wrote. If your servers run sessions with claude-rc (systemd + tmux), it shows the server and instance of each one, flags instances that are up with no session, and lists stopped instances you can start. Pick a row with Enter or a click and its icons appear: ↻ restart, X close (frees its RAM), ⎘ copy its claude.ai/code link. Restart and close ask for a second press.
  • Salud (health): CPU, RAM, disk, uptime and Claude processes of every device you list (laptop, servers, even a phone with Termux), with yellow/red warnings, a flag for devices without Claude Code, and the processes using the most memory on the one you pick.
  • Ayuda (help): keys, shortcuts, states and commands.

A Tips button in the menu turns on a bar with the shortcuts of the current screen.

Keyboard

Focus the pane with Ctrl+X Tab; Esc goes back to the prompt. Tab and the arrows move between rows and buttons, Enter presses. With the pane focused, letters work as shortcuts: p s y screens, t tips, r e c restart / close / copy url on the chosen row, o a u show offline, show stopped, refresh. The mouse works too.

Careful: the left arrow on an empty prompt (not in the pane) sends the session to the background and opens Claude Code's agent view.

Commands

/flota                       open the pane
/flota parar                 stop all reads and close the pane
/flota reiniciar             forget what was read and read everything again
/flota nueva <server> <instance> <dir> [--name X]
/flota apagar <server> <instance>

Install

At the prompt of a terminal session:

/plugin install flota --marketplace abelardodiaz/claude-code-mods

From a shell:

claude plugin install flota --marketplace abelardodiaz/claude-code-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with abelardodiaz/claude-code-mods, then Add plugin.

Answer y to add the marketplace and pick a scope. The options screen asks for:

  • Servers: comma-separated SSH hosts that run claude-rc, for example vps1,vps2. Empty means sessions only.
  • SSH command: ssh by default. On Windows with your keys inside WSL use wsl ssh.
  • Health devices: comma-separated devices for the health screen: laptop (this machine) and SSH hosts, for example laptop,vps1,phone.
  • TypeSafe key file: reserved for an upcoming feature (Jev reading each session's last message); leave empty.

Then type /flota.

Requirements and limits

  • Session list: a Claude Code build with mods and the ListAgents tool.
  • Servers: passwordless SSH (BatchMode=yes) and the claude-rc tool on each host (ls, up, restart, down, new, url). Sessions are matched to instances by the project number at the start of the session name (080-agent -> a directory containing -080-).
  • Health: any Linux or Termux host over SSH (/proc, df, ps), and the local machine (PowerShell on Windows, sh elsewhere).
  • Only simple names and paths (A-Z a-z 0-9 _ . / -) are passed to SSH, and only to the hosts you listed.
  • Local sessions have no claude-rc behind them, so they get no restart, close or url actions.

flota-widget (Windows)

The same Principal and Salud screens as a native Windows widget in widget/ (Win32 + GDI, no WebView, one ~400 KB .exe), for when no Claude Code session is open. Always on top, tray icon (click shows or hides, right click opens the menu with Start with Windows), drag it from anywhere, Esc hides it.

  • Local sessions come from claude agents --json; servers from claude-rc ls over SSH, like the mod.
  • It reads the mod's options (pluginConfigs["flota@claude-code-mods"] in ~/.claude/settings.json), so install and configure the mod first.
cd widget
cargo build --release
# target\release\flota-widget.exe

Español

Un panel estilo Turbo Pascal dentro de Claude Code para tu flota de sesiones y las máquinas donde corren. Tres pantallas, desde la barra de arriba:

  • Principal: todas tus sesiones (esta, las locales y las de Remote Control) con su estado, dónde corren y hace cuánto escribieron. Con claude-rc en tus servidores muestra servidor e instancia, avisa de instancias activas sin sesión y lista las apagadas para levantarlas. Eliges una fila con Enter o clic y aparecen sus iconos: ↻ reiniciar, X cerrar (libera su RAM), ⎘ copiar su enlace. Reiniciar y cerrar piden una segunda pulsación.
  • Salud: CPU, RAM, disco, tiempo encendido y procesos de Claude de cada dispositivo (laptop, servidores, hasta un celular con Termux), con avisos en amarillo y rojo, marca de los que no tienen Claude Code y los procesos que más memoria usan.
  • Ayuda: teclas, atajos, estados y comandos.

El botón Tips del menú prende una barra con los atajos de la pantalla actual.

Instalación: igual que arriba (/plugin install flota --marketplace abelardodiaz/claude-code-mods). En las opciones pon tus servidores y dispositivos separados por comas y, en Windows con las llaves en WSL, wsl ssh como comando de SSH. Luego escribe /flota.

flota-widget (Windows)

Las mismas pantallas Principal y Salud como widget nativo de Windows en widget/ (Win32 + GDI, sin WebView, un solo .exe de unos 400 KB), para cuando no tienes ninguna sesión de Claude Code abierta. Siempre encima, icono en la bandeja (clic muestra u oculta, clic derecho abre el menú con Iniciar con Windows), se arrastra desde cualquier punto y Esc lo oculta. Lee las opciones del mod, así que primero instala y configura el mod. Se compila con cargo build --release dentro de widget/.

License

MIT

Source 5 files
hooks/register.tsx 938 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Foto, Instancia, Pendiente, Registro, Servidores, Sesion } from '../types'
5import { ultimosRemotos } from './actividad'
6import type { Ultimo } from './actividad'
7import { juzgar } from './jev'
8type FilaTabla = { k: string; estado: string; color: string; nombre: string; donde: string; hace: string; host: string; inst: string; activa: boolean; rc: boolean }
9const ESTADO_LARGO: Record<string, string> = { aqui: 'esta sesion', trab: 'trabajando', esp: 'esperando', cerr: 'cerrando', off: 'sin conexion' }
10// Paleta VGA (Turbo Pascal). Azul = ventanas y tablas; cian = estado, tips y seleccion;
11// gris = menu, barra de abajo y modales; amarillo = titulos dentro de las ventanas.
12const AZUL = '#0000AA'
13const CIAN = '#00AAAA'
14const GRIS = '#AAAAAA'
15const AMARILLO = '#FFFF55'
16// Fondo de las filas de las tablas: sin fondo (el de la terminal). Para volver al azul: AZUL.
17const FONDO_TABLA: string | undefined = undefined
18// Un pedazo de linea con su color (para los titulos amarillos de las ventanas).
19type Seg = { t: string; c?: string }
20type Celda = { t: string; c?: string }
21type FilaLista = { k: string; celdas: Celda[] }
22import { leerSalud } from './salud'
23import type { Salud } from './salud'
24
25const PANE = 'flota'
26const CADA_MS = 15_000
27const SERVIDORES_CADA_MS = 5 * 60_000
28const ACTIVIDAD_CADA_MS = 2 * 60_000
29const SALUD_CADA_MS = 60_000
30const CONFIRMAR_MS = 6_000
31// Jev solo juzga mensajes recientes y por encima de este umbral los marca.
32const JUZGAR_DIAS = 3
33const JEV_ACTIVO = false
34const UMBRAL = 0.7
35// Opciones del plugin (userConfig). Sin servidores, el panel solo lista sesiones;
36// sin archivo de key de TypeSafe, no hay juicio de Jev.
37let HOSTS: string[] = []
38let SSH: string[] = ['ssh']
39let KEY_JEV = ''
40// Dispositivos de la pantalla de salud: 'laptop' y alias de ssh.
41let DISPOSITIVOS: string[] = ['laptop']
42// Simbolos de un solo caracter de ancho (los emojis de color ocupan dos y desalinean la tabla).
43const ICONO: Record<string, string> = { reiniciar: '↻', cerrar: 'X', levantar: '▶' }
44const SIN_PENDIENTE: Pendiente = { accion: '', host: '', inst: '', hasta: 0 }
45
46const foto = atom({ plugin: 'flota', key: 'foto' } as const, { sesiones: [], at: 0, error: '' })
47const servidores = atom({ plugin: 'flota', key: 'servidores' } as const, { instancias: [], at: 0, error: '' })
48const verFuera = atom({ plugin: 'flota', key: 'verFuera' } as const, false)
49const verApagadas = atom({ plugin: 'flota', key: 'verApagadas' } as const, false)
50const pendiente = atom({ plugin: 'flota', key: 'pendiente' } as const, SIN_PENDIENTE)
51const aviso = atom({ plugin: 'flota', key: 'aviso' } as const, '')
52const verMensajes = atom({ plugin: 'flota', key: 'verMensajes' } as const, true)
53const actividad = atom({ plugin: 'flota', key: 'actividad' } as const, {} as Record<string, Registro>)
54const errorActividad = atom({ plugin: 'flota', key: 'errorActividad' } as const, '')
55const pantalla = atom({ plugin: 'flota', key: 'pantalla' } as const, 'sesiones' as 'sesiones' | 'salud' | 'ayuda')
56const salud = atom({ plugin: 'flota', key: 'salud' } as const, {} as Record<string, Salud>)
57const dispSel = atom({ plugin: 'flota', key: 'dispSel' } as const, '')
58const filaSel = atom({ plugin: 'flota', key: 'filaSel' } as const, '')
59const verTips = atom({ plugin: 'flota', key: 'verTips' } as const, false)
60
61// "1.2G" a partir de KB.
62function gb(kb: number): string {
63  return kb >= 1048576 ? `${(kb / 1048576).toFixed(1)}G` : `${Math.round(kb / 1024)}M`
64}
65// Tiempo encendido con un solo formato y ancho fijo: " 2d 14h", " 0d 07h", "29d 03h".
66function dias(seg: number): string {
67  if (!seg) return '     -'
68  const d = Math.floor(seg / 86400)
69  const h = Math.floor((seg % 86400) / 3600)
70  return `${String(d).padStart(2)}d ${String(h).padStart(2, '0')}h`
71}
72const corto = (d: string) => d.replace(/^server/, 's')
73
74// "hace cuanto" corto para la columna ULTIMO.
75function hace(ms: number, ahora: number): string {
76  if (!ms) return '-'
77  const m = Math.max(0, Math.round((ahora - ms) / 60_000))
78  if (m < 60) return `${m}m`
79  if (m < 48 * 60) return `${Math.round(m / 60)}h`
80  return `${Math.round(m / 1440)}d`
81}
82
83// Solo nombres y rutas simples llegan al ssh: nada de comillas ni espacios.
84const SEGURO = /^[A-Za-z0-9_./-]+$/
85
86// El comando de ssh es configurable: en Windows suele ser "wsl ssh" (las llaves viven en WSL).
87async function claudeRc($: any, host: string, args: string[], timeoutMs = 30_000) {
88  if (!HOSTS.includes(host)) throw new Error(`servidor desconocido: ${host}`)
89  for (const a of args) if (!SEGURO.test(a)) throw new Error(`argumento no permitido: ${a}`)
90  const r = await $.process.run(
91    [...SSH, '-o', 'ConnectTimeout=6', '-o', 'BatchMode=yes', host, 'claude-rc', ...args],
92    { timeoutMs },
93  )
94  return { ok: r.exitCode === 0, out: String(r.stdout).trim(), err: String(r.stderr).trim() }
95}
96
97async function claudeRcLs($: any, host: string): Promise<Instancia[]> {
98  const r = await claudeRc($, host, ['ls'])
99  if (!r.ok) throw new Error(`${host}: ${r.err.slice(0, 120)}`)
100  const out: Instancia[] = []
101  for (const linea of r.out.split('\n').slice(1)) {
102    const c = linea.trim().split(/\s+/)
103    if (c.length >= 6) out.push({ host, inst: c[0] ?? '', active: c[1] === 'active', dir: c[c.length - 1] ?? '' })
104  }
105  return out
106}
107
108// "120-shop" -> instancia cuyo dir trae "-120-"; si hay varias, la que comparte
109// alguna palabra del nombre (120-shop-api vs 120-shop-web).
110function instanciaDe(s: Sesion, insts: Instancia[]): Instancia | null {
111  if (!s.kind.toLowerCase().includes('remote')) return null
112  const num = s.name.match(/^(\d{3,5})/)?.[1]
113  if (!num) return null
114  const cand = insts.filter(i => i.dir.includes(`-${num}-`))
115  const palabras = s.name.toLowerCase().split(/[-_ ]/).slice(1).filter(p => p.length > 2)
116  return (
117    cand.find(i => i.active && palabras.some(p => (i.inst + i.dir).toLowerCase().includes(p))) ??
118    cand.find(i => i.active) ??
119    cand[0] ??
120    null
121  )
122}
123
124// La salida de ListAgents trae filas con status libre ("idle", "busy", "offline"...).
125function grupo(status: string): 'activa' | 'espera' | 'fuera' {
126  const s = status.toLowerCase()
127  if (s.includes('offline')) return 'fuera'
128  if (s.includes('idle')) return 'espera'
129  return 'activa'
130}
131
132// Una misma sesion puede aparecer varias veces (instancias viejas offline).
133// Se queda la mejor: activa > espera > fuera.
134function dedup(lista: Sesion[]): Sesion[] {
135  const peso = { activa: 0, espera: 1, fuera: 2 }
136  const por = new Map<string, Sesion>()
137  for (const s of lista) {
138    const prev = por.get(s.name)
139    if (!prev || peso[grupo(s.status)] < peso[grupo(prev.status)]) por.set(s.name, s)
140  }
141  return [...por.values()].sort((a, b) => a.name.localeCompare(b.name))
142}
143
144// Los llena session.start; los botones y /flota los usan.
145let actualizarTodo: (() => Promise<void>) | null = null
146let actualizarServidores: (() => Promise<void>) | null = null
147let actualizarActividad: (() => Promise<void>) | null = null
148let actualizarSesiones: (() => Promise<void>) | null = null
149let actualizarSalud: (() => Promise<void>) | null = null
150let leyendoActividad = false
151// Foco: el ultimo elemento enfocado y los extremos visibles de la pantalla actual. Los botones
152// fantasma (atajos ocultos) entran al recorrido de Tab: cuando el foco cae en uno se salta.
153let ultimoFoco = ''
154const PRIMER_VISIBLE = 'ir:sesiones'
155let ultimoVisible = 'tips'
156let relojes: { cancel: () => void }[] = []
157let arrancar: (() => void) | null = null
158
159function detener() {
160  for (const r of relojes) r.cancel()
161  relojes = []
162}
163
164async function avisar($: any, texto: string) {
165  await update($, aviso, () => texto)
166  $.ui.toast(texto)
167}
168
169// Ejecuta una orden de claude-rc y deja el resultado en el panel.
170async function ejecutar($: any, accion: string, host: string, args: string[]) {
171  await avisar($, `${accion} ${host}/${args[1] ?? args[0]}...`)
172  try {
173    const r = await claudeRc($, host, args, 120_000)
174    const det = (r.ok ? r.out : r.err).split('\n').filter(Boolean).slice(-1)[0] ?? ''
175    await avisar($, `${r.ok ? 'OK' : 'FALLO'} ${accion} ${host}/${args[1] ?? ''}: ${det.slice(0, 140)}`)
176  } catch (err) {
177    await avisar($, `FALLO ${accion}: ${String(err)}`)
178  }
179  if (actualizarServidores) await actualizarServidores()
180  // Remote Control tarda en marcar una sesion apagada: se vuelve a mirar en 5 y 15 s.
181  if (actualizarSesiones) {
182    void actualizarSesiones()
183    for (const ms of [5_000, 15_000]) $.clock.after(ms, () => { if (actualizarSesiones) void actualizarSesiones() })
184  }
185}
186
187// Primera pulsacion: arma la accion. Segunda (antes de CONFIRMAR_MS): la ejecuta.
188async function pulsar($: any, accion: string, host: string, inst: string) {
189  const ahora = await $.clock.now()
190  const p: Pendiente = await read($, pendiente)
191  if (p.accion === accion && p.host === host && p.inst === inst && ahora < p.hasta) {
192    await update($, pendiente, () => SIN_PENDIENTE)
193    const orden: Record<string, string> = { levantar: 'up', reiniciar: 'restart', cerrar: 'down' }
194    await ejecutar($, accion, host, [orden[accion] ?? 'restart', inst])
195    return
196  }
197  await update($, pendiente, () => ({ accion, host, inst, hasta: ahora + CONFIRMAR_MS }))
198  $.clock.after(CONFIRMAR_MS, () => {
199    void update($, pendiente, q => (q.inst === inst && q.accion === accion ? SIN_PENDIENTE : q))
200  })
201}
202
203async function copiarUrl($: any, host: string, inst: string, surface: any) {
204  try {
205    const r = await claudeRc($, host, ['url', inst])
206    const url = r.out.match(/https?:\/\/\S+/)?.[0]
207    if (!url) return avisar($, `Sin URL para ${host}/${inst}: ${(r.err || r.out).slice(0, 120)}`)
208    await $.ui.copy({ text: url, surface })
209    await avisar($, `URL de ${inst} copiada`)
210  } catch (err) {
211    await avisar($, `FALLO url ${inst}: ${String(err)}`)
212  }
213}
214
215const AYUDA =
216  'Panel: p s y t (pantallas y tips), r e c (reiniciar, cerrar, url), o a u. Comandos: /flota | /flota parar | /flota reiniciar | /flota nueva <servidor> <inst> <dir> [--name X] | /flota apagar <servidor> <inst>'
217
218export const register: Register = (on, options) => {
219  const o = options as { hosts?: string; sshCommand?: string; typesafeKeyFile?: string; devices?: string }
220  HOSTS = (o.hosts ?? '').split(',').map(h => h.trim()).filter(h => SEGURO.test(h))
221  SSH = (o.sshCommand ?? 'ssh').split(/\s+/).filter(Boolean)
222  KEY_JEV = (o.typesafeKeyFile ?? '').trim()
223  DISPOSITIVOS = (o.devices ?? 'laptop').split(',').map(d => d.trim()).filter(d => SEGURO.test(d))
224  if (DISPOSITIVOS.length === 0) DISPOSITIVOS = ['laptop']
225
226  on('session.start', async ($, e, next) => {
227    await $.command.register({ name: 'flota', description: 'Panel de sesiones de la flota. ' + AYUDA })
228    // Limpia cualquier texto viejo que el mod haya dejado en la linea de estado.
229    $.ui.status(undefined)
230
231    const refrescar = async () => {
232      try {
233        const r = await $.tool.call({ tool: 'ListAgents' })
234        if ('deny' in r && r.deny) throw new Error('ListAgents rechazado: ' + r.deny)
235        type Fila = { name?: string; ref?: string; id?: string; type?: string; status?: string }
236        const res = (r.result ?? {}) as { listing?: string; sections?: { kind: string; rows: Fila[] }[] }
237        const filas: Sesion[] = []
238        for (const sec of res.sections ?? []) {
239          for (const row of sec.rows) {
240            filas.push({
241              name: row.name ?? row.id ?? '?',
242              ref: row.ref ?? '',
243              kind: row.type ?? sec.kind,
244              status: row.status ?? '',
245            })
246          }
247        }
248        // ListAgents no se lista a si misma; su nombre viene en la primera linea del texto.
249        const yo = (res.listing ?? '').match(/This session is (.+?) \[([0-9a-f]+)\]/)
250        if (yo) filas.push({ name: yo[1] ?? '?', ref: yo[2] ?? '', kind: 'interactive', status: 'aqui' })
251        // Respaldo: el texto trae filas "nombre [ref]  ·  tipo  ·  estado".
252        if (filas.length <= 1) {
253          for (const linea of (res.listing ?? '').split('\n')) {
254            const m = linea.match(/^\s+(.+?) \[([0-9a-f]+)\]\s+·\s+(.+?)\s+·\s+(\S+)/)
255            if (m) filas.push({ name: m[1] ?? '?', ref: m[2] ?? '', kind: m[3] ?? '', status: m[4] ?? '' })
256          }
257        }
258        const at = await $.clock.now()
259        await update($, foto, () => ({ sesiones: dedup(filas), at, error: '' }))
260      } catch (err) {
261        const at = await $.clock.now()
262        await update($, foto, f => ({ ...f, at, error: String(err) }))
263      }
264    }
265
266    actualizarSesiones = refrescar
267
268    actualizarServidores = async () => {
269      const instancias: Instancia[] = []
270      const errores: string[] = []
271      for (const host of HOSTS) {
272        try {
273          instancias.push(...(await claudeRcLs($, host)))
274        } catch (err) {
275          errores.push(String(err))
276        }
277      }
278      const at = await $.clock.now()
279      await update($, servidores, () => ({ instancias, at, error: errores.join(' | ') }))
280    }
281
282    // Ultimo mensaje de cada sesion (local y servidores) y, si cambio, juicio de Jev.
283    actualizarActividad = async () => {
284      if (leyendoActividad) return
285      leyendoActividad = true
286      try {
287        const sv: Servidores = await read($, servidores)
288        const errores: string[] = []
289        // Mezcla lo leido conservando el juicio de Jev si el texto no cambio.
290        const mezclar = async (nuevos: Record<string, Ultimo>) => {
291          await update($, actividad, previo => {
292            const sig = { ...previo }
293            for (const [clave, u] of Object.entries(nuevos)) {
294              const p = previo[clave]
295              sig[clave] = p && p.juzgado === u.texto
296                ? { ...p, texto: u.texto, at: u.at }
297                : { texto: u.texto, at: u.at, necesita: -1, atorada: -1, error: '', juzgado: '' }
298            }
299            return sig
300          })
301        }
302        // Solo servidores: la lectura local (PowerShell sobre transcripts de varios MB) tardaba
303        // mas de 90 s y gastaba CPU y RAM en la laptop. Queda en actividad.ts por si se retoma.
304        await Promise.all(
305          HOSTS.map(host =>
306            ultimosRemotos($, SSH, host, sv.instancias).then(mezclar, err => void errores.push(`${host}: ${String(err)}`)),
307          ),
308        )
309        await update($, errorActividad, () => errores.join(' | ').slice(0, 300))
310        const ahora = await $.clock.now()
311        const siguiente: Record<string, Registro> = await read($, actividad)
312        // Jev apagado por ahora (JEV_ACTIVO); el juicio sigue en jev.ts.
313        if (!JEV_ACTIVO || KEY_JEV === '') return
314        for (const [clave, r] of Object.entries(siguiente)) {
315          if (!r.texto || r.juzgado === r.texto || ahora - r.at > JUZGAR_DIAS * 86_400_000) continue
316          const j = await juzgar($, KEY_JEV, clave, r.texto)
317          await update($, actividad, a => ({ ...a, [clave]: { ...r, ...j, juzgado: j.error ? '' : r.texto } }))
318        }
319      } finally {
320        leyendoActividad = false
321      }
322    }
323
324    actualizarTodo = async () => {
325      await Promise.all([refrescar(), actualizarServidores ? actualizarServidores() : Promise.resolve()])
326      if (actualizarActividad) await actualizarActividad()
327    }
328    actualizarSalud = async () => {
329      await Promise.all(
330        DISPOSITIVOS.map(async d => {
331          const r = await leerSalud($, SSH, d)
332          await update($, salud, m => ({ ...m, [d]: r }))
333        }),
334      )
335    }
336
337    // Los relojes se guardan para que /flota parar los detenga.
338    arrancar = () => {
339      detener()
340      if (actualizarTodo) void actualizarTodo()
341      relojes = [
342        $.clock.every(CADA_MS, () => void refrescar()),
343        $.clock.every(SERVIDORES_CADA_MS, () => {
344          if (actualizarServidores) void actualizarServidores()
345        }),
346        $.clock.every(ACTIVIDAD_CADA_MS, () => {
347          if (actualizarActividad) void actualizarActividad()
348        }),
349        $.clock.every(SALUD_CADA_MS, () => {
350          void read($, pantalla).then(p => { if (p === 'salud' && actualizarSalud) void actualizarSalud() })
351        }),
352      ]
353    }
354    // Preferencias guardadas: al reactivar el mod el panel queda como estaba.
355    const vf = await $.store.get('verFuera')
356    const va = await $.store.get('verApagadas')
357    const vt = await $.store.get('verTips')
358    if (typeof vt === 'boolean') await update($, verTips, () => vt)
359    if (typeof vf === 'boolean') await update($, verFuera, () => vf)
360    if (typeof va === 'boolean') await update($, verApagadas, () => va)
361    arrancar()
362
363    return next(e)
364  })
365
366  on('command.run', { command: 'flota' }, async ($, e) => {
367    const p = e.args.trim().split(/\s+/).filter(Boolean)
368    // Ordenes escritas por la persona: se ejecutan sin segunda confirmacion.
369    if (p[0] === 'nueva') {
370      const [, host, inst, dir, ...resto] = p
371      if (!host || !inst || !dir) return { text: AYUDA }
372      void ejecutar($, 'nueva', host, ['new', inst, dir, ...resto])
373      await $.ui.open({ id: PANE, title: 'Flota' })
374      return { text: `Creando ${inst} en ${host}; el resultado sale en el panel.` }
375    }
376    if (p[0] === 'apagar') {
377      const [, host, inst] = p
378      if (!host || !inst) return { text: AYUDA }
379      void ejecutar($, 'apagar', host, ['down', inst])
380      await $.ui.open({ id: PANE, title: 'Flota' })
381      return { text: `Apagando ${inst} en ${host}; el resultado sale en el panel.` }
382    }
383    if (p[0] === 'parar') {
384      detener()
385      await $.ui.close({ id: PANE })
386      return { text: 'Flota parada: sin lecturas ni llamadas a Jev. /flota la vuelve a arrancar.' }
387    }
388    if (p[0] === 'reiniciar') {
389      // Borra lo leido y lo juzgado, y vuelve a empezar.
390      await update($, actividad, () => ({}))
391      await update($, errorActividad, () => '')
392      await update($, aviso, () => '')
393      if (arrancar) arrancar()
394      await $.ui.open({ id: PANE, title: 'Flota' })
395      return { text: 'Flota reiniciada: releyendo sesiones, servidores y mensajes.' }
396    }
397    if (p.length > 0) return { text: AYUDA }
398    await $.ui.open({ id: PANE, title: 'Flota' })
399    // Cada apertura empieza sin sesion elegida.
400    await update($, filaSel, () => '')
401    // Parada o no, abrir el panel la (re)arranca.
402    if (relojes.length === 0 && arrancar) arrancar()
403    else if (actualizarTodo) void actualizarTodo()
404    return { text: 'Panel de la flota abierto; actualizando sesiones y servidores.' }
405  })
406
407  // Tab no pasa a las paradas del motor (la X de cerrar del panel): por ahi se salia el foco.
408  // Sin next el foco se queda donde estaba. Esc sigue devolviendo el teclado al chat.
409  on('ui.focus', async ($, e, next) => {
410    const quien = (e.origin as { kind?: string } | undefined)?.kind
411    if (e.requestId === PANE && !e.element && quien === 'person') return {}
412    // Fantasma: si veniamos del primer visible estamos retrocediendo -> al ultimo visible;
413    // si no, avanzando -> da la vuelta al primero del menu.
414    if (e.requestId === PANE && e.element?.startsWith('hk:') && quien === 'person') {
415      const destino = ultimoFoco === PRIMER_VISIBLE ? ultimoVisible : PRIMER_VISIBLE
416      ultimoFoco = destino
417      return next({ ...e, element: destino })
418    }
419    if (e.requestId === PANE && e.element) ultimoFoco = e.element
420    return next(e)
421  })
422
423  on('ui.message', async ($, e, next) => {
424    // Atajos reenviados por la tabla o la lista cuando ellas tienen el teclado.
425    const atajo = ((e.data ?? {}) as { atajo?: string }).atajo
426    if ((e.element === 'tabla' || e.element === 'lista-salud') && atajo) {
427      if (atajo === 'sesiones' || atajo === 'salud' || atajo === 'ayuda') {
428        await update($, pantalla, () => atajo)
429        if (atajo === 'salud' && actualizarSalud) void actualizarSalud()
430      } else if (atajo === 'actualizar' && actualizarTodo) void actualizarTodo()
431      else if (atajo === 'actualizarSalud' && actualizarSalud) void actualizarSalud()
432      else if (atajo === 'sinConexion') await update($, verFuera, v => (void $.store.set('verFuera', !v), !v))
433      else if (atajo === 'apagadas') await update($, verApagadas, v => (void $.store.set('verApagadas', !v), !v))
434      return {}
435    }
436    // Lista de salud: elegir dispositivo o volver a la principal.
437    if (e.element === 'lista-salud') {
438      const d = (e.data ?? {}) as { elegir?: string; ir?: string }
439      if (d.elegir) await update($, dispSel, () => d.elegir as string)
440      if (d.ir === 'sesiones') await update($, pantalla, () => 'sesiones')
441      return {}
442    }
443    if (e.element !== 'tabla') return next(e)
444    const d = (e.data ?? {}) as { accion?: string; host?: string; inst?: string }
445    if (!d.accion || !d.host || !d.inst) return {}
446    if (d.accion === 'url') void copiarUrl($, d.host, d.inst, e.surface)
447    else if (d.accion === 'reiniciar' || d.accion === 'cerrar') void pulsar($, d.accion, d.host, d.inst)
448    return {}
449  })
450
451  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
452    const els = $.ui.resolve(e)
453    const { Box, Text, Button } = els
454    const f: Foto = await read($, foto)
455    const sv: Servidores = await read($, servidores)
456    const mostrarFuera: boolean = await read($, verFuera)
457    const mostrarApagadas: boolean = await read($, verApagadas)
458    const p: Pendiente = await read($, pendiente)
459    const ultimo: string = await read($, aviso)
460    const mostrarMensajes: boolean = await read($, verMensajes)
461    const act: Record<string, Registro> = await read($, actividad)
462    const errAct: string = await read($, errorActividad)
463    const pant = await read($, pantalla)
464    // Lo comun a las tres pantallas: barra verde de ubicacion arriba y menu abajo.
465    const props = ((e as any).props ?? {}) as { isFocused?: boolean; bodyColumns?: number; scroll?: { bodyRows?: number } }
466    const foco = props.isFocused === true
467    const NOMBRE: Record<string, string> = { sesiones: 'PRINCIPAL', salud: 'SALUD', ayuda: 'AYUDA' }
468    const TECLAS: Record<string, string> = {
469      sesiones: 'flechas/Tab moverse  Enter pulsar  la fila enfocada queda elegida',
470      salud: 'flechas/Tab moverse  Enter pulsar  el dispositivo enfocado queda elegido',
471      ayuda: '',
472    }
473    // Estilo Turbo Pascal (Borland). Cada color significa una sola cosa:
474    // gris = barras del sistema (menu arriba, estado abajo), verde = seleccion/foco,
475    // azul con borde doble = ventanas de contenido, cian = resumen, gris con borde simple = modal.
476    const W = Math.max(40, (props.bodyColumns ?? 80) - 1)
477    const llenar = (t: string, n = W) => (t.length >= n ? t.slice(0, n) : t.padEnd(n))
478    const franja = (t: string, fg: string, bg: string) => (
479      <Text color={fg} backgroundColor={bg} bold>{llenar(t)}</Text>
480    )
481    const tips: boolean = await read($, verTips)
482    const estado = (t: string) => franja(` ${t}  | teclado: ${foco ? 'PANEL' : 'CHAT (ctrl+x tab)'}`, 'black', CIAN)
483    // Barra verde de tips: aparece solo con el boton Tips del menu.
484    // Una idea por linea: iconos, atajos y movimiento.
485    const TIPS: Record<string, string[]> = {
486      sesiones: [
487        'Iconos de la fila elegida:  ↻ reiniciar    X cerrar    ⎘ copiar url',
488        'Atajos:  R reiniciar   E cerrar   C copiar url   O ver off   A apagadas   U actualizar',
489        'Moverse: Tab o flechas    Elegir: Enter o clic    Menu: P S Y T    Esc: al chat',
490      ],
491      salud: [
492        'Moverse: Tab o flechas    Elegir dispositivo: Enter o clic',
493        'Atajos:  U actualizar    Menu: P S Y T    Esc: al chat',
494      ],
495      ayuda: ['Tips: prende y apaga esta barra con T o con el boton Tips del menu'],
496    }
497    const barra = tips ? (
498      <Box flexDirection="column">{(TIPS[pant] ?? []).map(t => franja(` ${t}`, 'black', CIAN))}</Box>
499    ) : null
500    // Ventana: titulo en el borde de arriba, como las de Turbo Pascal.
501    const ventana = (titulo: string, lineas: (string | Seg[])[], fg = 'white', bg = AZUL, doble = false) => {
502      const ancho = W - 2
503      // (no llamar "h" a nada: es la funcion global con la que se dibuja el JSX)
504      const [hz, v, a1, a2, b1, b2] = doble ? ['═', '║', '╔', '╗', '╚', '╝'] : ['─', '│', '┌', '┐', '└', '┘']
505      const tit = ` ${titulo} `.slice(0, Math.max(0, ancho - 4))
506      const arriba = a1 + hz + tit + hz.repeat(Math.max(0, ancho - 1 - tit.length)) + a2
507      return [
508        <Text color={fg} backgroundColor={bg} bold>{arriba}</Text>,
509        ...lineas.map(l => {
510          if (typeof l === 'string') return <Text color={fg} backgroundColor={bg}>{v + llenar(` ${l}`, ancho) + v}</Text>
511          // Recorta los pedazos para que la linea nunca pase el ancho (si pasa, se parte y descuadra).
512          let quedan = ancho - 1
513          const segs = l.map(x => {
514            const t = x.t.slice(0, Math.max(0, quedan))
515            quedan -= t.length
516            return { ...x, t }
517          })
518          const largo = 1 + segs.reduce((n, x) => n + x.t.length, 0)
519          return (
520            <Text color={fg} backgroundColor={bg}>
521              {v + ' '}
522              {segs.map(x => (x.c ? <Text color={x.c} bold>{x.t}</Text> : x.t))}
523              {' '.repeat(Math.max(0, ancho - largo)) + v}
524            </Text>
525          )
526        }),
527        <Text color={fg} backgroundColor={bg} bold>{b1 + hz.repeat(ancho) + b2}</Text>,
528      ]
529    }
530    // Boton estilo Turbo Pascal: plano (sin corchetes) y SIN hotkey, porque un plano con
531    // hotkey antepone "p:" en el color de acento del motor. La letra roja en mayuscula es solo
532    // guia visual; se pulsa con Tab/flechas + Enter o con el mouse.
533    type Item = { key: string; hk: string; etiqueta: string; onPress: (e: any) => void; verde?: boolean }
534    const largoItem = (it: Item) => it.etiqueta.length + 2
535    const tecla = (it: Item) => {
536      const fondo = it.verde ? CIAN : GRIS
537      const i = it.etiqueta.toLowerCase().indexOf(it.hk)
538      return (
539        <Button key={it.key} plain onPress={it.onPress}>
540          <Text backgroundColor={fondo} color="black">
541            {' '}
542            {i >= 0 ? it.etiqueta.slice(0, i) : it.etiqueta}
543            {i >= 0 && <Text color="red" bold>{it.etiqueta[i]!.toUpperCase()}</Text>}
544            {i >= 0 ? it.etiqueta.slice(i + 1) : ''}
545            {' '}
546          </Text>
547        </Button>
548      )
549    }
550    // Barra gris de lado a lado con los botones seguidos, sin divisiones.
551    const barraBotones = (items: Item[], prefijo = '', derecha = '') => {
552      const usado = prefijo.length + items.reduce((n, it) => n + largoItem(it), 0) + derecha.length
553      return (
554        <Box flexDirection="row">
555          {prefijo !== '' && <Text backgroundColor={GRIS} color="black" bold>{prefijo}</Text>}
556          {items.map(tecla)}
557          <Text backgroundColor={GRIS}>{' '.repeat(Math.max(0, W - usado))}</Text>
558          {derecha !== '' && <Text backgroundColor={GRIS} color="black" bold>{derecha}</Text>}
559        </Box>
560      )
561    }
562    // PRUEBA: botones "fantasma" en un contenedor oculto, solo para que las letras funcionen
563    // como atajo sin dibujar "p:" ni corchetes. Si el motor desactiva los atajos de lo oculto,
564    // esto no hace nada y se quita.
565    const fantasmas = (items: Item[]) => (
566      <Box display="none">
567        {items.map(it => (
568          <Button key={`hk:${it.key}`} plain hotkey={it.hk} onPress={it.onPress}>
569            {it.etiqueta}
570          </Button>
571        ))}
572      </Box>
573    )
574    // Fecha y hora de la maquina que corre Claude Code, corta y en 24 h.
575    const reloj = (() => {
576      const d = new Date()
577      const dos = (n: number) => String(n).padStart(2, '0')
578      return ` ${dos(d.getDate())}/${dos(d.getMonth() + 1)} ${dos(d.getHours())}:${dos(d.getMinutes())} `
579    })()
580    const irA = (q: 'sesiones' | 'salud' | 'ayuda') => {
581      void update($, pantalla, () => q)
582      if (q === 'salud' && actualizarSalud) void actualizarSalud()
583    }
584    const OPCIONES: readonly (readonly ['sesiones' | 'salud' | 'ayuda', string, string])[] = [
585      ['sesiones', 'p', 'principal'],
586      ['salud', 's', 'salud'],
587      ['ayuda', 'y', 'ayuda'],
588    ]
589    // Menu arriba, en la barra gris, como el de Turbo Pascal.
590    const itemsMenu: Item[] = OPCIONES.map(([q, hk, etiqueta]) => ({
591        key: `ir:${q}`,
592        hk,
593        etiqueta: etiqueta[0]!.toUpperCase() + etiqueta.slice(1),
594        onPress: () => irA(q),
595        verde: pant === q,
596      })).concat([
597        {
598          key: 'tips',
599          hk: 't',
600          etiqueta: 'Tips',
601          onPress: () => void update($, verTips, v => (void $.store.set('verTips', !v), !v)),
602          verde: tips,
603        },
604      ])
605    const menu = (
606      <Box flexDirection="column">
607        {barraBotones(itemsMenu, ' ≡ FLOTA  ', reloj)}
608      </Box>
609    )
610
611    if (pant === 'ayuda') {
612      ultimoVisible = 'tips'
613      // Dos columnas: tecla o termino en amarillo, explicacion en blanco.
614      const fila = (a: string, b: string): Seg[] => [{ t: a.padEnd(18), c: AMARILLO }, { t: b }]
615      const aviso = (t: string): Seg[] => [{ t: '¡CUIDADO!'.padEnd(18), c: 'red' }, { t }]
616      return (
617        <Box flexDirection="column">
618          {menu}
619          {barra}
620          <Text> </Text>
621          {ventana('Teclado', [
622            fila('ctrl+x tab', 'el teclado pasa al panel (la linea de abajo dice PANEL)'),
623            fila('Esc', 'el teclado regresa al chat'),
624            fila('Tab, flechas', 'moverse entre filas y botones'),
625            fila('Enter o clic', 'pulsar el boton o elegir la fila (marca ►)'),
626            fila('flecha izq/der', 'en el panel: control anterior / siguiente (como Shift+Tab / Tab)'),
627            aviso('flecha izq en el CHAT (prompt vacio): sesion a 2o plano'),
628            fila('ctrl+x x', 'cierra el panel'),
629            fila('ctrl+x izq / der', 'agranda / achica el panel'),
630            fila('AvPag RePag', 'desplaza el panel (tambien Inicio y Fin)'),
631          ])}
632          <Text> </Text>
633          {ventana('Atajos (con el panel enfocado)', [
634            fila('P  S  Y', 'pantallas Principal, Salud y aYuda'),
635            fila('T', 'prende o apaga la barra de tips'),
636            fila('O  A  U', 'ver off, ver apagadas, actualizar (Principal)'),
637            fila('R  E  C', 'reiniciar, cerrar, copiar url de la sesion elegida'),
638            fila('dos veces', 'reiniciar y cerrar se confirman pulsando otra vez'),
639          ])}
640          <Text> </Text>
641          {ventana('Estados de una sesion', [
642            fila('esta sesion', 'la sesion donde corre este panel'),
643            fila('trabajando', 'Claude esta procesando un turno'),
644            fila('esperando', 'sesion viva, esperando instrucciones'),
645            fila('cerrando', 'el servidor ya la detuvo; Remote Control aun no se entera'),
646            fila('sin conexion', 'entrada vieja de Remote Control; se borra desde la app'),
647            fila('apagadas', 'instancias de servidor detenidas; se levantan con su boton ▶'),
648            fila('● (columna RC)', 'tiene Remote Control conocido'),
649            fila('sin (Salud)', 'el dispositivo no tiene Claude Code instalado'),
650          ])}
651          <Text> </Text>
652          {ventana('Comandos', [
653            fila('/flota', 'abre el panel'),
654            fila('/flota parar', 'detiene lecturas y cierra el panel'),
655            fila('/flota reiniciar', 'borra lo leido y vuelve a leer todo'),
656            fila('/flota nueva', '<servidor> <inst> <dir> [--name X]  crea una instancia'),
657            fila('/flota apagar', '<servidor> <inst>  apaga una instancia'),
658          ])}
659          {estado('AYUDA')}
660          {fantasmas(itemsMenu)}
661        </Box>
662      )
663    }
664
665    if (pant === 'salud') {
666      const sl: Record<string, Salud> = await read($, salud)
667      const sel0: string = await read($, dispSel)
668      const elegido = DISPOSITIVOS.includes(sel0) ? sel0 : DISPOSITIVOS[0] ?? 'laptop'
669      const c = (t: string, n: number) => (t.length > n - 1 ? t.slice(0, n - 2) + '~ ' : t.padEnd(n))
670      const pct = (a: number, b: number) => (b > 0 ? Math.round((a / b) * 100) : 0)
671      const color = (v: number, amarillo: number, rojo: number) => (v >= rojo ? 'red' : v >= amarillo ? 'yellow' : 'green')
672      const det = sl[elegido]
673      const sinClaude = DISPOSITIVOS.filter(d => sl[d] && !sl[d]!.error && !sl[d]!.tieneClaude)
674      const sinRespuesta = DISPOSITIVOS.filter(d => sl[d]?.error).length
675      // Con alerta: RAM >= 85%, disco >= 90% o carga por encima de los nucleos.
676      const alertas = DISPOSITIVOS.filter(d => {
677        const x = sl[d]
678        if (!x || x.error) return false
679        const carga = x.cpupct >= 0 ? x.cpupct : (x.load / Math.max(1, x.cpus)) * 100
680        return 100 - pct(x.memAvail, x.memTotal) >= 85 || pct(x.diskUsed, x.diskTotal) >= 90 || carga >= 100
681      }).length
682      // Informacion del dispositivo elegido: 5 lineas en dos columnas alineadas.
683      const par = (a: string, va: string, b = '', vb = ''): Seg[] =>
684        b
685          ? [{ t: a.padEnd(11), c: AMARILLO }, { t: va.padEnd(Math.max(24, Math.floor((W - 30) / 2))) }, { t: b.padEnd(12), c: AMARILLO }, { t: vb }]
686          : [{ t: a.padEnd(11), c: AMARILLO }, { t: va }]
687      const lineasDisp: (string | Seg[])[] = !det
688        ? [`${corto(elegido)}: leyendo...`, '', '', '', '']
689        : det.error
690          ? [par('Disp', corto(elegido), 'Estado', 'sin respuesta'), par('Error', det.error), '', '', '']
691          : [
692              par('Disp', corto(elegido), 'Encendido', dias(det.up)),
693              par('CPU', det.cpupct >= 0 ? `${det.cpupct}%` : `carga ${det.load.toFixed(2)} en ${det.cpus} nucleos`, 'Claude', det.tieneClaude ? `${det.claude} procesos` : 'NO instalado'),
694              par('RAM', `${100 - pct(det.memAvail, det.memTotal)}% de ${gb(det.memTotal)}`, 'claude-rc', det.rc >= 0 ? `${det.rc} activas` : '-'),
695              par('Disco', `${pct(det.diskUsed, det.diskTotal)}% de ${gb(det.diskTotal)} en ${det.mount || '?'}`),
696              par('Memoria', det.top.slice(0, 4).map(t => `${t.nombre} ${gb(t.kb)}`).join('   ')),
697            ]
698      // Una fila por dispositivo, en celdas con color, para la lista con barra (lista.tsx).
699      const filasSalud: FilaLista[] = DISPOSITIVOS.map(d => {
700        const x = sl[d]
701        if (!x) return { k: d, celdas: [{ t: c(corto(d), 10) }, { t: 'leyendo...', c: 'gray' }] }
702        if (x.error) return { k: d, celdas: [{ t: c(corto(d), 10) }, { t: x.error, c: 'red' }] }
703        const carga = x.cpupct >= 0 ? x.cpupct : Math.round((x.load / Math.max(1, x.cpus)) * 100)
704        const cpuTxt = x.cpupct >= 0 ? `${x.cpupct}%` : `${x.load.toFixed(2)}/${x.cpus}`
705        const ram = 100 - pct(x.memAvail, x.memTotal)
706        const disco = pct(x.diskUsed, x.diskTotal)
707        return {
708          k: d,
709          celdas: [
710            { t: c(corto(d), 10) },
711            { t: c(cpuTxt, 10), c: color(carga, 70, 100) },
712            { t: c(`${ram}% ${gb(x.memTotal)}`, 13), c: color(ram, 70, 85) },
713            { t: c(`${disco}% ${gb(x.diskTotal)}`, 13), c: color(disco, 80, 90) },
714            { t: c(dias(x.up), 9) },
715            x.tieneClaude ? { t: c(String(x.claude), 8) } : { t: c('sin', 8), c: 'magenta' },
716            { t: x.rc >= 0 ? String(x.rc) : '-' },
717          ],
718        }
719      })
720      ultimoVisible = 'actualizar-salud'
721      const itemsSalud: Item[] = [{ key: 'actualizar-salud', hk: 'u', etiqueta: 'Actualizar', onPress: () => { if (actualizarSalud) void actualizarSalud() } }]
722      return (
723        <Box flexDirection="column">
724          {menu}
725          {barra}
726          <Text> </Text>
727          {ventana(corto(elegido), lineasDisp)}
728          <Text> </Text>
729          <Text backgroundColor={GRIS} color={AZUL} bold>{llenar(c('', 2) + c('DISP', 10) + c('CPU', 10) + c('RAM', 13) + c('DISCO', 13) + c('ENCEND', 9) + c('CLAUDE', 8) + 'RC')}</Text>
730          {filasSalud.map(fl => {
731            const largo = 2 + fl.celdas.reduce((n, x) => n + x.t.length, 0)
732            return (
733              <Button key={`disp:${fl.k}`} plain onPress={() => void update($, dispSel, () => fl.k)}>
734                <Text backgroundColor={FONDO_TABLA} color="white" bold={fl.k === elegido}>
735                  {fl.k === elegido ? '► ' : '  '}
736                  {fl.celdas.map(x => (x.c ? <Text color={x.c}>{x.t}</Text> : x.t))}
737                  {' '.repeat(Math.max(0, W - largo))}
738                </Text>
739              </Button>
740            )
741          })}
742          <Text> </Text>
743          {barraBotones(itemsSalud)}
744          {estado(
745            `SALUD  ${DISPOSITIVOS.length} dispositivos | ${alertas} con alerta | ${sinRespuesta} sin respuesta | ` +
746              `${sinClaude.length} sin Claude | cada 60 s`,
747          )}
748          {fantasmas([...itemsMenu, ...itemsSalud])}
749        </Box>
750      )
751    }
752
753    const ahora = await $.clock.now()
754    const activas = f.sesiones.filter(s => grupo(s.status) === 'activa')
755    const espera = f.sesiones.filter(s => grupo(s.status) === 'espera')
756    const fuera = f.sesiones.filter(s => grupo(s.status) === 'fuera')
757    const hora = f.at ? new Date(f.at).toTimeString().slice(0, 8) : '--'
758
759    const filas = [...activas, ...espera, ...(mostrarFuera ? fuera : [])]
760      .map(s => {
761        const g = grupo(s.status)
762        const aqui = s.status === 'aqui'
763        const remota = s.kind.toLowerCase().includes('remote')
764        // Una sesion sin conexion no corre en ningun lado: no se le busca servidor.
765        const inst = g === 'fuera' ? null : instanciaDe(s, sv.instancias)
766        const reg = act[inst ? `${inst.host}/${inst.inst}` : s.name]
767        return {
768          estado: aqui ? 'aqui' : inst && !inst.active ? 'cerr' : g === 'activa' ? 'trab' : g === 'espera' ? 'esp' : 'off',
769          color: aqui ? 'cyan' : inst && !inst.active ? 'gray' : g === 'activa' ? 'green' : g === 'espera' ? 'yellow' : 'gray',
770          nombre: s.name,
771          servidor: inst ? inst.host : g === 'fuera' ? '-' : remota ? 'laptop (?)' : 'laptop',
772          inst,
773          reg,
774          // Remote Control conocido: sesion Remote Control o instancia de servidor.
775          rc: g !== 'fuera' && (remota || Boolean(inst)),
776        }
777      })
778      .sort((a, b) => {
779        const orden: Record<string, number> = { aqui: 0, trab: 1, esp: 2, cerr: 3, off: 4 }
780        return (orden[a.estado] ?? 3) - (orden[b.estado] ?? 3) || a.servidor.localeCompare(b.servidor) || a.nombre.localeCompare(b.nombre)
781      })
782    // Lo que no es nombre: marca 2 + estado 13 + RC 3 + donde 13 + hace 5 + iconos 21.
783    const tope = Math.max(10, Math.min(28, (props.bodyColumns ?? 80) - 58))
784    const ancho = Math.min(tope, Math.max(6, ...filas.map(r => r.nombre.length))) + 1
785    // Servidor e instancia en una columna: "s003/t109", "laptop".
786    const donde = (r: { servidor: string; inst: Instancia | null }) =>
787      r.inst ? `${r.inst.host.replace('server', 's')}/${r.inst.inst}` : r.servidor
788    const col = (t: string, n: number) => (t.length > n - 1 ? t.slice(0, n - 2) + '~ ' : t.padEnd(n))
789
790    // Instancias que el servidor dice activas pero que no aparecen como sesion.
791    const nombres = f.sesiones.filter(s => grupo(s.status) !== 'fuera').map(s => s.name)
792    const huerfanas = sv.instancias.filter(i => {
793      if (!i.active) return false
794      const num = i.dir.match(/-(\d{3,5})-/)?.[1] ?? ''
795      return num !== '' && !nombres.some(n => n.startsWith(num))
796    })
797    const apagadas = sv.instancias.filter(i => !i.active)
798
799    // Boton con confirmacion: la primera pulsacion cambia la etiqueta.
800    const boton = (accion: string, i: Instancia) => {
801      const armado = p.accion === accion && p.host === i.host && p.inst === i.inst
802      return (
803        <Button key={`${accion}:${i.host}:${i.inst}`} onPress={() => void pulsar($, accion, i.host, i.inst)}>
804          {armado ? <Text color="red">{`¿${accion} ${i.inst}?`}</Text> : (ICONO[accion] ?? accion)}
805        </Button>
806      )
807    }
808    // Datos para la tabla navegable (tabla.tsx): solo texto plano, sin funciones.
809    const filasTabla: FilaTabla[] = filas.map(r => ({
810      k: r.nombre, // unico: dos sesiones pueden compartir instancia (120-shop y 120-shop-web)
811      estado: r.estado,
812      color: r.color,
813      nombre: r.nombre,
814      donde: donde(r),
815      hace: hace(r.reg?.at ?? 0, ahora),
816      host: r.inst?.host ?? '',
817      inst: r.inst?.inst ?? '',
818      activa: Boolean(r.inst?.active),
819      rc: r.rc,
820    }))
821    const selGuardada: string = await read($, filaSel)
822    // Sin seleccion por defecto: se elige con Enter o clic.
823    const selK = filasTabla.some(r => r.k === selGuardada) ? selGuardada : ''
824    const fs = filasTabla.find(r => r.k === selK)
825    const armadoEn = (accion: string) => Boolean(fs && p.accion === accion && p.host === fs.host && p.inst === fs.inst)
826
827    // Informacion de la sesion elegida: 5 lineas en dos columnas alineadas.
828    const par = (a: string, va: string, b = '', vb = ''): Seg[] =>
829      b
830        ? [{ t: a.padEnd(11), c: AMARILLO }, { t: va.padEnd(Math.max(24, Math.floor((W - 30) / 2))) }, { t: b.padEnd(12), c: AMARILLO }, { t: vb }]
831        : [{ t: a.padEnd(11), c: AMARILLO }, { t: va }]
832    const rs = filas.find(r => r.nombre === selK)
833    const lineasSel: (string | Seg[])[] = !fs || !rs
834      ? ['Elige una sesion con flechas o Tab y pulsa Enter (o clic).', '', '', '']
835      : [
836          par('Sesion', fs.nombre, 'Estado', ESTADO_LARGO[fs.estado] ?? fs.estado),
837          par('Servidor', rs.inst ? rs.inst.host : fs.estado === 'off' ? '-' : 'laptop', 'Instancia', rs.inst ? rs.inst.inst : '-'),
838          par(
839            'Carpeta',
840            rs.inst
841              ? rs.inst.dir
842              : fs.estado === 'off'
843                ? 'entrada vieja de Remote Control (no corre; se borra en la app)'
844                : 'sesion local en esta laptop (se maneja desde su ventana)',
845          ),
846          par('Actividad', fs.hace !== '-' ? `hace ${fs.hace}` : 'sin dato'),
847        ]
848
849    ultimoVisible = 'actualizar'
850    const itemsPrincipal: Item[] = [
851      { key: 'ver-fuera', hk: 'o', etiqueta: `${mostrarFuera ? 'Ocultar' : 'Ver'} off (${fuera.length})`, onPress: () => void update($, verFuera, v => (void $.store.set('verFuera', !v), !v)) },
852      { key: 'ver-apagadas', hk: 'a', etiqueta: `${mostrarApagadas ? 'Ocultar' : 'Ver'} apagadas (${apagadas.length})`, onPress: () => void update($, verApagadas, v => (void $.store.set('verApagadas', !v), !v)) },
853      { key: 'actualizar', hk: 'u', etiqueta: 'Actualizar', onPress: () => { if (actualizarTodo) void actualizarTodo() } },
854    ]
855    return (
856      <Box flexDirection="column">
857        {menu}
858        {barra}
859        <Text> </Text>
860        {ventana(fs ? fs.nombre : 'Sesion', lineasSel)}
861        {(armadoEn('reiniciar') || armadoEn('cerrar')) &&
862          ventana(
863            'Confirmar',
864            [armadoEn('cerrar') ? `Cerrar ${fs?.inst} y liberar su RAM?  Pulsa X (o E) otra vez.` : `Reiniciar ${fs?.inst}?  Pulsa ↻ (o R) otra vez.`],
865            'black',
866            GRIS,
867            false,
868          )}
869        <Text> </Text>
870        {f.error !== '' && <Text color="red">Error: {f.error}</Text>}
871        {errAct !== '' && <Text color="red">Servidores (actividad): {errAct}</Text>}
872        <Text backgroundColor={GRIS} color={AZUL} bold>{llenar('  ' + col('ESTADO', 13) + col('SESION', ancho) + col('RC', 3) + col('DONDE', 13) + 'HACE')}</Text>
873        {filasTabla.map(r => {
874          const elegida = r.k === selK
875          const iconos = elegida && r.activa
876          // Con iconos la fila deja 21 columnas para [ ↻ ] [ X ] [ ⎘ ].
877          const resto = Math.max(0, W - 2 - 13 - ancho - 3 - (iconos ? 21 : 0))
878          return (
879            <Box flexDirection="row">
880              <Button key={`fila:${r.k}`} plain onPress={() => void update($, filaSel, () => r.k)}>
881                <Text backgroundColor={FONDO_TABLA} color="white" bold={elegida}>
882                  {elegida ? '► ' : '  '}
883                  <Text color={r.color === 'gray' ? 'white' : r.color}>{col(ESTADO_LARGO[r.estado] ?? r.estado, 13)}</Text>
884                  {col(r.nombre, ancho)}
885                  <Text color="green">{col(r.rc ? '●' : '', 3)}</Text>
886                  {llenar(col(r.donde, 13) + col(r.hace, 5), resto)}
887                </Text>
888              </Button>
889              {iconos && (
890                <Box flexDirection="row">
891                  <Button key="icono-r" hotkey="r" onPress={() => void pulsar($, 'reiniciar', r.host, r.inst)}>
892                    {armadoEn('reiniciar') ? <Text color="red">¿↻?</Text> : '↻'}
893                  </Button>
894                  <Button key="icono-e" hotkey="e" onPress={() => void pulsar($, 'cerrar', r.host, r.inst)}>
895                    {armadoEn('cerrar') ? <Text color="red">¿X?</Text> : 'X'}
896                  </Button>
897                  <Button key="icono-c" hotkey="c" onPress={press => void copiarUrl($, r.host, r.inst, press.surface)}>
898                    ⎘
899                  </Button>
900                </Box>
901              )}
902            </Box>
903          )
904        })}
905
906        {huerfanas.length > 0 && <Text> </Text>}
907        {huerfanas.length > 0 && <Text color="red">Activas en servidor pero sin sesion:</Text>}
908        {huerfanas.map(i => (
909          <Box flexDirection="row">
910            <Text color="red">{'  ' + col(i.host, 12) + col(i.inst, 18)}</Text>
911            {boton('reiniciar', i)}
912          </Box>
913        ))}
914
915        {mostrarApagadas && apagadas.length > 0 && <Text> </Text>}
916        {mostrarApagadas && apagadas.length > 0 && <Text bold>Instancias apagadas</Text>}
917        {mostrarApagadas &&
918          apagadas.map(i => (
919            <Box flexDirection="row">
920              <Text dimColor>{'  ' + col(i.host, 12) + col(i.inst, 18) + col(i.dir.split('/').pop() ?? '', 30)}</Text>
921              {boton('levantar', i)}
922            </Box>
923          ))}
924
925        <Text> </Text>
926        {barraBotones(itemsPrincipal)}
927        {estado(
928          `${activas.length} TRABAJANDO | ${espera.length} ESPERANDO | ${fuera.length} SIN CONEXION | ` +
929            `${apagadas.length} APAGADAS | act ${hora}`,
930        )}
931        {ultimo !== '' && <Text color={ultimo.startsWith('FALLO') ? 'red' : 'cyan'}>{ultimo}</Text>}
932        {sv.error !== '' && <Text color="red">Servidores: {sv.error}</Text>}
933        {fantasmas([...itemsMenu, ...itemsPrincipal])}
934      </Box>
935    )
936  })
937}
938
hooks/actividad.ts 163 lines
1// Lee el ultimo mensaje del asistente de cada sesion, de su transcript (.jsonl).
2// Local: ~/.claude/projects/*/*.jsonl (el nombre sale de la linea "agent-name").
3// Servidores: ~/.claude/projects/<dir de la instancia con / -> -> por ssh.
4
5import type { Instancia } from '../types'
6
7export type Ultimo = { texto: string; at: number }
8
9const MAX_TEXTO = 600
10const LOCAL_DIAS = 2
11const LOCAL_MAX = 15
12
13// base64 propio: el modulo no tiene btoa garantizado.
14function b64(s: string): string {
15  return b64Bytes(Array.from(new TextEncoder().encode(s)))
16}
17
18
19function b64Bytes(bytes: number[]): string {
20  const t = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
21  let out = ''
22  for (let i = 0; i < bytes.length; i += 3) {
23    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
24    const n = (a << 16) | (b << 8) | c
25    out += t[(n >> 18) & 63]! + t[(n >> 12) & 63]! + (i + 1 < bytes.length ? t[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? t[n & 63]! : '=')
26  }
27  return out
28}
29
30// Una linea del transcript del tipo assistant con bloques de texto -> texto + hora.
31function textoDeLinea(linea: string): Ultimo | null {
32  try {
33    const o = JSON.parse(linea)
34    const partes = (o?.message?.content ?? []) as { type?: string; text?: string }[]
35    const texto = partes.filter(p => p.type === 'text' && p.text).map(p => p.text).join('\n').trim()
36    if (!texto) return null
37    return { texto: texto.slice(-MAX_TEXTO), at: Date.parse(o.timestamp ?? '') || 0 }
38  } catch {
39    return null
40  }
41}
42
43// Carpeta de proyecto como la nombra Claude Code: todo lo no alfanumerico -> '-'.
44export function slug(dir: string): string {
45  return dir.replace(/[^A-Za-z0-9]/g, '-')
46}
47
48// Una llamada ssh por servidor para todas sus instancias activas.
49export async function ultimosRemotos($: any, ssh: string[], host: string, insts: Instancia[]): Promise<Record<string, Ultimo>> {
50  const activas = insts.filter(i => i.active && i.host === host)
51  if (activas.length === 0) return {}
52  const script = activas
53    .map(i => {
54      const s = slug(i.dir)
55      // El transcript actual puede estar vacio (sesion recien reiniciada): el mas reciente con contenido.
56      return [
57        `f=""; for x in $(ls -t ~/.claude/projects/${s}/*.jsonl 2>/dev/null); do [ -s "$x" ] && f="$x" && break; done`,
58        `echo "@@${i.inst} $( [ -n "$f" ] && stat -c %Y "$f" )"`,
59        `[ -n "$f" ] && tail -n 400 "$f" | grep '"type":"assistant"' | grep '"type":"text"' | tail -n 1`,
60      ].join('; ')
61    })
62    .join('\n')
63  // El script viaja en base64 (sin $ ni comillas): wsl lo pasa por un bash local, y asi
64  // nada se expande aqui; Windows lo envuelve en comillas dobles por llevar espacios.
65  const r = await $.process.run(
66    [...ssh, '-o', 'ConnectTimeout=6', '-o', 'BatchMode=yes', host, `echo ${b64(script)} | base64 -d | bash`],
67    { timeoutMs: 45_000 },
68  )
69  const out: Record<string, Ultimo> = {}
70  let actual = ''
71  let mtime = 0
72  for (const linea of String(r.stdout).split('\n')) {
73    if (linea.startsWith('@@')) {
74      const [inst = '', seg = '0'] = linea.slice(2).trim().split(/\s+/)
75      actual = inst
76      mtime = Number(seg) * 1000 || 0
77      // Sin texto del asistente: al menos la fecha de la ultima actividad.
78      if (actual && mtime) out[`${host}/${actual}`] = { texto: '', at: mtime }
79    } else if (actual && linea.startsWith('{')) {
80      const u = textoDeLinea(linea)
81      if (u) out[`${host}/${actual}`] = { texto: u.texto, at: u.at || mtime }
82    }
83  }
84  return out
85}
86
87// Cache: ruta del transcript -> nombre de la sesion (rara vez cambia).
88const nombrePorArchivo = new Map<string, string>()
89
90export async function ultimosLocales($: any): Promise<Record<string, Ultimo>> {
91  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
92  if (!home) return {}
93  const raiz = `${home}/.claude/projects`.replace(/\\/g, '/')
94  const ahora = await $.clock.now()
95  const archivos: { path: string; mtime: number }[] = []
96  for (const d of await $.fs.list(raiz)) {
97    if (d.kind !== 'dir') continue
98    for (const f of await $.fs.list(`${raiz}/${d.name}`).catch(() => [])) {
99      if (f.kind === 'file' && f.name.endsWith('.jsonl') && ahora - f.mtimeMs < LOCAL_DIAS * 86_400_000) {
100        archivos.push({ path: `${raiz}/${d.name}/${f.name}`, mtime: f.mtimeMs })
101      }
102    }
103  }
104  archivos.sort((a, b) => b.mtime - a.mtime)
105  const lista = archivos.slice(0, LOCAL_MAX)
106  if (lista.length === 0) return {}
107
108  const windows = /^[A-Za-z]:/.test(home)
109  let salida = ''
110  if (windows) {
111    const ps = [
112      '[Console]::OutputEncoding=[Text.Encoding]::UTF8',
113      ...lista.map(a => {
114        const p = a.path.replace(/'/g, "''")
115        const buscaNombre = nombrePorArchivo.has(a.path)
116          ? ''
117          : `$n=Select-String -LiteralPath '${p}' -Pattern '"type":"agent-name"' -SimpleMatch | Select-Object -Last 1; if($n){$n.Line};`
118        return `"@@${p}"; ${buscaNombre} $t=Get-Content -LiteralPath '${p}' -Tail 400 -Encoding UTF8 | Where-Object { $_.Contains('"type":"assistant"') -and $_.Contains('"type":"text"') } | Select-Object -Last 1; if($t){$t}`
119      }),
120    ].join('\n')
121    // El script va por stdin: en la linea de comandos -Command se come las comillas dobles
122    // y -EncodedCommand revienta el limite de 32K caracteres de Windows con 25 archivos.
123    // Con "-Command -" PowerShell ejecuta linea por linea; cada archivo es una sola linea.
124    const r = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', '-'], {
125      stdin: ps + '\n',
126      timeoutMs: 90_000,
127    })
128    salida = String(r.stdout)
129  } else {
130    const sh = lista
131      .map(a => {
132        const p = a.path.replace(/'/g, `'\\''`)
133        const buscaNombre = nombrePorArchivo.has(a.path) ? '' : `grep -F '"type":"agent-name"' '${p}' | tail -n 1;`
134        return `echo '@@${p}'; ${buscaNombre} tail -n 400 '${p}' | grep -F '"type":"assistant"' | grep -F '"type":"text"' | tail -n 1`
135      })
136      .join('\n')
137    const r = await $.process.run(['sh', '-c', sh], { timeoutMs: 60_000 })
138    salida = String(r.stdout)
139  }
140
141  // Por nombre, el archivo mas reciente gana (la lista ya viene ordenada).
142  const out: Record<string, Ultimo> = {}
143  let actual = ''
144  for (const linea of salida.split('\n')) {
145    if (linea.startsWith('@@')) {
146      actual = linea.slice(2).trim()
147      continue
148    }
149    if (!actual || !linea.startsWith('{')) continue
150    if (linea.includes('"type":"agent-name"')) {
151      try {
152        const n = JSON.parse(linea)?.agentName
153        if (n) nombrePorArchivo.set(actual, String(n))
154      } catch {}
155      continue
156    }
157    const nombre = nombrePorArchivo.get(actual)
158    const u = textoDeLinea(linea)
159    if (nombre && u && !out[nombre]) out[nombre] = u
160  }
161  return out
162}
163
hooks/jev.ts 51 lines
1// Jev (TypeSafe) lee el ultimo mensaje de una sesion y dice si te necesita o si esta atorada.
2// Una llamada por mensaje nuevo; el resultado se guarda y no se repite mientras no cambie.
3
4export type Juicio = { necesita: number; atorada: number; error: string }
5
6const MODELO = 'jev-1.14.0' // version fija: la calibracion cambia entre versiones
7const URL = 'https://api.typesafe.ai/v1/systemone'
8
9let key: string | null = null
10
11async function leerKey($: any, archivo: string): Promise<string> {
12  if (key !== null) return key
13  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
14  const ruta = archivo.replace(/^~/, home)
15  key = String(await $.fs.read(ruta)).trim()
16  return key
17}
18
19export async function juzgar($: any, archivoKey: string, sesion: string, mensaje: string): Promise<Juicio> {
20  try {
21    const k = await leerKey($, archivoKey)
22    const body = {
23      model: MODELO,
24      state: { sesion, mensaje },
25      questions: {
26        necesita: {
27          type: 'noul',
28          instructions:
29            'In `mensaje`, the last message an AI coding assistant wrote in its session, is the assistant waiting for the human user to answer a question, make a decision, approve something or do a manual step before it can continue?',
30        },
31        atorada: {
32          type: 'noul',
33          instructions:
34            'Does `mensaje` report an error, failure or blocker that the assistant could not resolve by itself?',
35        },
36      },
37    }
38    const r = await $.http.fetch(URL, {
39      method: 'POST',
40      headers: { Authorization: `Bearer ${k}`, 'Content-Type': 'application/json' },
41      body: JSON.stringify(body),
42    })
43    // Un fallo de API no es una decision del modelo: se reporta aparte.
44    if (!r.ok) return { necesita: -1, atorada: -1, error: `HTTP ${r.status}` }
45    const a = JSON.parse(r.text)?.answers ?? {}
46    return { necesita: Number(a.necesita?.noul ?? -1), atorada: Number(a.atorada?.noul ?? -1), error: '' }
47  } catch (err) {
48    return { necesita: -1, atorada: -1, error: String(err).slice(0, 80) }
49  }
50}
51
hooks/salud.ts 106 lines
1// Salud de un dispositivo: CPU, RAM, disco, tiempo encendido, procesos de Claude y los
2// procesos que mas memoria usan. Servidores y celular (Termux) por ssh con un script POSIX;
3// la laptop Windows con PowerShell (por stdin: -Command se come las comillas).
4
5import type { Salud } from '../types'
6export type { Salud }
7
8// Disco de $HOME: en el celular "/" es el sistema de Android y siempre sale al 100%.
9// Android (Termux) niega /proc/uptime y /proc/loadavg: respaldo con el comando uptime.
10const SCRIPT_POSIX = [
11  'u=$(cut -d. -f1 /proc/uptime 2>/dev/null); [ -z "$u" ] && u=$(( $(date +%s) - $(date -d "$(uptime -s)" +%s 2>/dev/null || date +%s) ))',
12  'echo "up=$u"',
13  'l=$(cut -d" " -f1 /proc/loadavg 2>/dev/null); [ -z "$l" ] && l=$(uptime | sed "s/.*average: *//" | cut -d, -f1)',
14  'echo "load=$l"',
15  'echo "cpus=$(nproc 2>/dev/null || echo 1)"',
16  `awk '/MemTotal/{t=$2}/MemAvailable/{a=$2}END{print "mem="t" "a}' /proc/meminfo`,
17  `df -Pk "$HOME" | awk 'NR==2{print "disk="$2" "$3" "$6}'`,
18  'echo "claude=$(pgrep -f claude 2>/dev/null | wc -l)"',
19  // ssh no interactivo no siempre trae ~/.local/bin en el PATH: se mira tambien ahi.
20  'if command -v claude >/dev/null 2>&1 || [ -x "$HOME/.local/bin/claude" ]; then echo "tieneclaude=1"; else echo "tieneclaude=0"; fi',
21  `if command -v claude-rc >/dev/null 2>&1; then echo "rc=$(claude-rc ls 2>/dev/null | awk 'NR>1 && $2=="active"' | wc -l)"; else echo "rc=-1"; fi`,
22  // Nombre del ejecutable (no comm, que Android corta); Claude aparece como su version.
23  `ps -eo rss,args 2>/dev/null | awk 'NR>1' | sort -rn | head -5 | awk '{n=$2; sub(/.*\\//,"",n); print "top="$1" "n}'`,
24].join('\n')
25
26const SCRIPT_WINDOWS = [
27  '$os=Get-CimInstance Win32_OperatingSystem',
28  '$cpu=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average',
29  '$d=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID=\'C:\'"',
30  '"up=" + [int]((Get-Date)-$os.LastBootUpTime).TotalSeconds',
31  '"cpupct=$cpu"',
32  '"cpus=$env:NUMBER_OF_PROCESSORS"',
33  '"mem=$($os.TotalVisibleMemorySize) $($os.FreePhysicalMemory)"',
34  '"disk=$([int64]($d.Size/1KB)) $([int64](($d.Size-$d.FreeSpace)/1KB)) C:"',
35  '"claude=" + @(Get-Process -Name claude -ErrorAction SilentlyContinue).Count',
36  '"tieneclaude=" + [int][bool](Get-Command claude -ErrorAction SilentlyContinue)',
37  'Get-Process | Sort-Object WS -Descending | Select-Object -First 5 | ForEach-Object { "top=$([int64]($_.WS/1KB)) $($_.ProcessName)" }',
38].join('\n')
39
40function b64(s: string): string {
41  const bytes = Array.from(new TextEncoder().encode(s))
42  const t = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
43  let out = ''
44  for (let i = 0; i < bytes.length; i += 3) {
45    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
46    const n = (a << 16) | (b << 8) | c
47    out += t[(n >> 18) & 63]! + t[(n >> 12) & 63]! + (i + 1 < bytes.length ? t[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? t[n & 63]! : '=')
48  }
49  return out
50}
51
52function parsear(disp: string, salida: string, at: number): Salud {
53  const s: Salud = {
54    disp, error: '', at, up: 0, load: -1, cpupct: -1, cpus: 1, memTotal: 0, memAvail: 0,
55    diskTotal: 0, diskUsed: 0, mount: '', claude: 0, rc: -1, tieneClaude: false, top: [],
56  }
57  for (const cruda of salida.split('\n')) {
58    const linea = cruda.trim()
59    const i = linea.indexOf('=')
60    if (i < 1) continue
61    const k = linea.slice(0, i)
62    const v = linea.slice(i + 1).trim()
63    const p = v.split(/\s+/)
64    if (k === 'up') s.up = Number(v) || 0
65    else if (k === 'load') s.load = Number(v)
66    else if (k === 'cpupct') s.cpupct = Number(v)
67    else if (k === 'cpus') s.cpus = Number(v) || 1
68    else if (k === 'mem') { s.memTotal = Number(p[0]) || 0; s.memAvail = Number(p[1]) || 0 }
69    else if (k === 'disk') { s.diskTotal = Number(p[0]) || 0; s.diskUsed = Number(p[1]) || 0; s.mount = p[2] ?? '' }
70    else if (k === 'claude') s.claude = Number(v) || 0
71    else if (k === 'rc') s.rc = Number(v)
72    else if (k === 'tieneclaude') s.tieneClaude = v === '1'
73    else if (k === 'top') {
74      const n = p.slice(1).join(' ')
75      s.top.push({ kb: Number(p[0]) || 0, nombre: /^\d+\.\d+\.\d+$/.test(n) ? `claude ${n}` : n })
76    }
77  }
78  if (!s.memTotal) s.error = 'sin datos'
79  return s
80}
81
82// disp: "laptop" (esta maquina) o un alias de ssh.
83export async function leerSalud($: any, ssh: string[], disp: string): Promise<Salud> {
84  const at = await $.clock.now()
85  try {
86    if (disp === 'laptop') {
87      const home = (await $.env.get('USERPROFILE')) ?? ''
88      const r = /^[A-Za-z]:/.test(home)
89        ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', '-'], { stdin: SCRIPT_WINDOWS + '\n', timeoutMs: 30_000 })
90        : await $.process.run(['sh', '-c', SCRIPT_POSIX], { timeoutMs: 20_000 })
91      return parsear(disp, String(r.stdout), at)
92    }
93    // Mismo truco que ultimosRemotos: base64 en un solo argumento.
94    const r = await $.process.run(
95      [...ssh, '-o', 'ConnectTimeout=8', '-o', 'BatchMode=yes', disp, `echo ${b64(SCRIPT_POSIX)} | base64 -d | sh`],
96      { timeoutMs: 30_000 },
97    )
98    if (r.exitCode !== 0 && !String(r.stdout).includes('mem=')) {
99      return { ...parsear(disp, '', at), error: String(r.stderr).trim().split('\n').pop()?.slice(0, 100) || `salida ${r.exitCode}` }
100    }
101    return parsear(disp, String(r.stdout), at)
102  } catch (err) {
103    return { ...parsear(disp, '', at), error: String(err).slice(0, 100) }
104  }
105}
106
types/index.d.ts 88 lines
1export type Sesion = {
2  name: string
3  ref: string
4  kind: string
5  status: string
6}
7
8export type Foto = {
9  sesiones: Sesion[]
10  at: number
11  error: string
12}
13
14// Una instancia de claude-rc en un servidor (salida de `claude-rc ls`).
15export type Instancia = {
16  host: string
17  inst: string
18  active: boolean
19  dir: string
20}
21
22export type Servidores = {
23  instancias: Instancia[]
24  at: number
25  error: string
26}
27
28// Accion que espera la segunda pulsacion para ejecutarse.
29export type Pendiente = {
30  accion: string
31  host: string
32  inst: string
33  hasta: number
34}
35
36// Ultimo mensaje del asistente de una sesion y lo que Jev dijo de el.
37// Clave: el nombre de la sesion (local) o "<host>/<instancia>" (servidor).
38// necesita/atorada: probabilidad 0-1 de Jev; -1 = sin juicio (sin key, sin texto o fallo).
39export type Registro = {
40  texto: string
41  at: number
42  necesita: number
43  atorada: number
44  error: string
45  juzgado: string
46}
47
48// Salud de un dispositivo (pantalla de salud). KB y segundos; -1 = no aplica.
49export type Salud = {
50  disp: string
51  error: string
52  at: number
53  up: number
54  load: number
55  cpupct: number
56  cpus: number
57  memTotal: number
58  memAvail: number
59  diskTotal: number
60  diskUsed: number
61  mount: string
62  claude: number
63  rc: number
64  tieneClaude: boolean
65  top: { kb: number; nombre: string }[]
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    flota: {
71      foto: Foto
72      servidores: Servidores
73      verFuera: boolean
74      verApagadas: boolean
75      verMensajes: boolean
76      pendiente: Pendiente
77      aviso: string
78      actividad: Record<string, Registro>
79      errorActividad: string
80      pantalla: 'sesiones' | 'salud' | 'ayuda'
81      salud: Record<string, Salud>
82      dispSel: string
83      filaSel: string
84      verTips: boolean
85    }
86  }
87}
88