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

Small mods (function-hook plugins) that add live panes and buttons to Claude Code.
Versión en español abajo.
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:
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.A Tips button in the menu turns on a bar with the shortcuts of the current screen.
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.
/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>
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:
claude-rc, for example vps1,vps2. Empty means sessions only.ssh by default. On Windows with your keys inside WSL use wsl ssh.laptop (this machine) and SSH hosts, for example laptop,vps1,phone.Then type /flota.
ListAgents tool.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-)./proc, df, ps), and the local machine (PowerShell on Windows, sh elsewhere).A-Z a-z 0-9 _ . / -) are passed to SSH, and only to the hosts you listed.claude-rc behind them, so they get no restart, close or url actions.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.
claude agents --json; servers from claude-rc ls over SSH, like the mod.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
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:
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.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.
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/.
MIT
hooks/register.tsx 938 lines1import { 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}
938hooks/actividad.ts 163 lines1// 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}
163hooks/jev.ts 51 lines1// 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}
51hooks/salud.ts 106 lines1// 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}
106types/index.d.ts 88 lines1export 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