vigilō.
La office corre cinco Claudes a la vez y ninguno puede tocarte la puerta. Una sesión que te espera es trabajo detenido que no sabe pedir ayuda: se queda en ámbar dentro de una caja en Alemania mientras tú estás en otra cosa. vigilō mira las cinco pantallas por ti, te busca cuando una necesita una mano, y te deja darle esa mano sin sentarte.
Nada que te espere puede quedarse callado.
Y nada que te llame puede exigir que te sientes.
La primera mitad es el aviso. La segunda es la respuesta desde la tarjeta. Un tablero que solo mira cumple media ley — y por eso no cambia nada.
Una hoja de contactos donde los negativos están vivos.
El chrome es lino: la pared clara de una sala de vigilancia. Las únicas superficies de tinta son las pantallas — y están encendidas, con los colores reales de cada sesión. Al tocar una, esa mini-pantalla crece hasta ocupar todo. El cambio es espacial, no un cambio de tema: no entras a otra app, te acercas a la que ya estabas mirando.
Dos maestros de verdad, ninguno estrujado. Cámbialo aquí mismo — es la misma app.
Cada dato de la tarjeta sale de un sitio real, y el que no existe no se inventa: el cliente se deriva del nombre de la sesión y, si no matchea ninguno conocido —code, por ejemplo—, la tarjeta sencillamente no lleva chip. El repo sale de pane_current_path; la tarea y el progreso, de expresiones sobre el propio snapshot; la edad, de session_created.
El teléfono
Una columna, la tarjeta es la unidad y el pulgar manda. Lo que te espera flota arriba — el orden no es el de tmux, es el de tu atención. La barra de teclas aparece al entrar a la terminal, porque un teclado de iPhone no puede manejar un TUI sin esc, tab ni flechas.
El desktop
Rejilla de auto-fill minmax(320px) — tres o cuatro columnas según la ventana — y el teclado como camino de primera clase: j/k para moverte, ↵ para entrar, 1-9 para responder, esc para volver. Nunca hay que memorizar nada: los números están dibujados en los botones.
Cinco luces, porque se contestan distinto.
El diseño original tenía cuatro. Al aceptar que se puede responder desde la tarjeta, el ámbar se partió en dos: una sesión que te pregunta trae sus opciones y se resuelve con un toque; una que te espera tiene el prompt vacío y hay que escribirle. Es el mismo color de urgencia y son dos herramientas distintas. Un estado que no cambia lo que puedes hacer no merece ser un estado.
Las dos luces ámbar no se distinguen solo por el color: la que pregunta late —tiene un halo que pulsa— y la que espera es un aro hueco. El color nunca carga solo el significado; siempre lo acompañan la forma, la etiqueta y lo que la tarjeta te deja hacer.
Sin adornos
Esto es heurística por firmas de texto, no una API. vigilō lee lo que se ve en el pane, igual que lo leerías tú. Va a acertar en lo común —lo que ya vimos en las cinco sesiones reales de la caja— y va a fallar en algún caso raro.
La mitigación es de diseño, no de fe: todas las firmas viven en un solo archivo. Afinar el detector es editar una lista, no refactorizar nada. Y la frontera difícil —«te espera» contra «ociosa»— se resuelve con el tiempo transcurrido desde la última entrega, que es el dato menos ambiguo que hay.
Aprobar es editar, también en una terminal.
Es la mitad de la ley que casi ningún dashboard cumple. Saber que algo te espera y no poder contestarlo desde donde estás no es información: es ansiedad con buena tipografía. La tarjeta ámbar se abre en el sitio —sin pantalla aparte, sin salir de la grilla— y te da las tres formas de contestar, en orden de esfuerzo.
Los botones son sus opciones
Cuando Claude pregunta, ofrece opciones numeradas. vigilō las lee del pane y las dibuja tal cual: si ofrece tres, ves tres, con su número y su texto. No es un menú nuestro de «aprobar / rechazar» encima del suyo — eso mentiría en cuanto la pregunta no fuera binaria, que es casi siempre.
La guardia de obsolescencia
El fallo más caro posible: decides sobre una pregunta, y para cuando tocas el botón, esa pregunta ya se contestó sola y hay otra en pantalla. Apruebas la anterior sobre la siguiente. Por eso cada respuesta lleva la huella del snapshot que la originó; si el pane cambió mientras decidías, vigilō no manda nada y te muestra lo nuevo.
Y ves el eco, no lo que creíste enviar
Al mandar, la tarjeta no se pinta optimista con tu texto: espera el siguiente snapshot y te muestra lo que de verdad llegó al pane. En una terminal, la diferencia entre «lo mandé» y «llegó» es todo — un carácter que se comió el modo bracket-paste ya es otro comando.
Los atajos que tú defines
Las tres cosas que dices todo el día no deberían escribirse todo el día. continúa · commítealo · para y explícame: definidos por ti una vez, disponibles en cualquier sesión. En el teléfono son la fila sobre el teclado; en desktop, teclas numéricas. Y son texto plano que tú escribes, no comandos nuestros: si mañana quieres que uno diga «hazme el PR con la plantilla de siempre», lo editas y ya.
La misma sesión, no una copia ni un reflejo.
Cuando el toque no alcanza, la mini-pantalla crece y adentro está la sesión real: teclado completo, shift+tab, los /comandos, el scroll, todo. El puente es node-pty contra tmux, y los colores ANSI se mapean a la paleta de la casa para que ni el interior de la terminal se sienta ajeno.
Lo que no puede pasar
Al hacer attach normal, tmux encoge la sesión al tamaño del cliente más pequeño: abrir vigilō en el teléfono te aplastaría la ventana que tienes abierta en Termius. La sesión agrupada (tmux new-session -t) es la mitad de la solución — comparte las ventanas y tiene su propio tamaño — pero no basta: desde tmux 2.9 la opción window-size vale latest por defecto, así que la ventana compartida sigue al último cliente que la tocó y el visor web te encoge igual.
Se cierra fijando window-size largest sobre la sesión efímera y nunca sobre la tuya, dentro de la misma invocación de tmux para que no exista ni un instante en el que la efímera viva sin la opción puesta.
Moverte sin volver
Dijiste que lo esencial era moverte fácil entre sesiones, así que la barra lleva ‹ › y el teclado j/k: saltas de una a otra sin pasar por la grilla. En el teléfono, deslizando. La grilla es el mapa, no el peaje.
La verificación que estaba mal
La primera comprobación de esto decía «178×64 antes y después» y era falsa. La sesión agrupada se creó con -d, detached — y así el resize nunca se dispara. El fallo solo apareció cuando un pty se adjuntó de verdad: 178×64 → 100×29, exactamente lo que el diseño juraba evitar.
Queda escrito aquí porque la lección es de diseño, no de tmux: una comprobación que no reproduce el camino real no comprueba nada. Es el criterio con el que están escritos los cierres de las seis fases — por eso el de la fase 2 no dice «la terminal funciona», dice que window_width de tu sesión en Termius devuelva lo mismo antes y después.
Por qué la URL tiene que cambiar.
Aquí está la condición técnica que gobierna todo el proyecto, y conviene decirla antes que nada: los navegadores exigen contexto seguro para service workers, Notification y Web Push. Una IP del tailnet por HTTP plano —http://100.x.y.z:<puerto>— no lo es. Sin HTTPS no hay notificación de ningún tipo, ni siquiera con la pestaña abierta.
La salida es limpia y no cuesta nada: tailscale serve pone HTTPS real delante, con certificado de Let's Encrypt renovado solo, y sin exponer nada a internet — eso sería funnel, que no vamos a usar nunca para un servicio que da una shell.
una sola vez, en la caja
# HTTPS del tailnet, sin abrir un solo puerto al mundo $ tailscale serve --bg <puerto> # queda servido en: https://office.<tailnet>.ts.net ← contexto seguro ✓
Los tres niveles
- La pestaña. El título pasa a
(1) vigilōy el favicon se pone ámbar. Gratis, siempre. - El sistema, en el Mac. Notificación nativa de Chrome que al tocarla abre esa sesión, no la portada.
- El teléfono. Push real — con la condición de iOS: solo existe si añades vigilō a la pantalla de inicio (16.4+). No es un capricho de Apple que podamos rodear; es la única puerta.
Dos reglas del aviso
- Un aviso por transición, no por estado. Una sesión que lleva cuarenta minutos en ámbar no te escribe ochocientas veces. Se avisa cuando cambia a ámbar, y una sola vez. Y puedes silenciar una sesión por una hora — vuelve sola, como el canal silenciado de al día.
- El aviso lleva el llamado, no el contenido. Ese payload viaja por los servidores de Apple o Google para llegar a tu bolsillo. Dice «cresco te espera» y nada más; el texto del prompt se busca al abrir, ya dentro del tailnet. Lo que no sale, no se filtra.
Un solo proceso, dos caminos de datos.
- la grilla
- xterm.js al entrar
- PWA instalada
- HTTPS + cert auto
- solo tailnet
- identidad del tailnet
- HTTP grilla · 3s
- WS terminal · node-pty
- systemd --user
La separación de los dos caminos es deliberada. La grilla nunca abre ptys —cinco sesiones no son cinco procesos por mirarlas— y hace polling barato cada tres segundos, así que si se cae la red se recupera sola en el siguiente tick. La terminal nunca hace polling: abre un WebSocket al entrar y lo cierra al salir.
Sin framework y sin build step: node con ws y node-pty, xterm.js servido local, un servicio systemd --user que arranca solo y sobrevive reinicios —igual que el Docker rootless de la caja—. Editas un archivo y recargas.
Multi-usuario: cada quien lo suyo
La office tiene varios usuarios. Cada uno corre su propio vigilō y ve su propio tmux: sin permisos cruzados, sin decisiones de privacidad que tomar, sin un servicio privilegiado que sea un blanco. tailscale serve —que vive a nivel de máquina— mapea una ruta por usuario a su puerto.
Esto entrega una shell. Digámoslo así.
Quien alcance este servicio es tu usuario en tu caja, con todo lo que eso implica. No hay forma de suavizarlo y no vale la pena intentarlo: lo honesto es enumerar las capas que hay delante y ser explícito sobre cuál es de verdad y cuál es un cerrojo.
- ufw solo deja pasar tailscale0. La caja no es alcanzable por ninguna otra interfaz. Esta es la capa que de verdad sostiene todo.
- El proceso escucha en loopback, detrás de
serve. Ni siquiera está expuesto dentro del tailnet por su cuenta. - La ACL de Tailscale limita a los invitados compartidos a SSH; no llegan al servicio.
- La identidad del tailnet.
serveinyecta cabeceras con quién eres al proxear, así que el servicio puede saber quién entra en vez de que conoce el link. Es una mejora real sobre el token en la URL — y hay que verificarla en la caja antes de confiarle nada. Si no se sostiene, se cae al token, dicho como lo que es: un cerrojo, no una cerradura.
Lo que no vamos a hacer
tailscale funnel— sería internet abierto contra una shell.- Atarlo a
0.0.0.0«mientras probamos». - Guardar el contenido de las sesiones. Los snapshots son de paso: se piden, se pintan, se olvidan.
Siete superficies, y ninguna de más.
Todo lo que hace vigilō cabe en siete rutas. Las de lectura son baratas y sin estado; las de escritura son tres, y las tres son explícitas sobre lo que van a hacer.
{ text, huella } y es la única ruta que puede devolver 409.La forma de una sesión
El contrato que hace posible que la tarjeta dibuje sus opciones: el objeto ask existe solo cuando el estado es ask, y trae la huella dentro. El cliente no puede fabricar una respuesta sin ella porque no la tiene por otro camino.
GET /api/sessions · un elemento
{
"name": "cresco", "repo": "cresco-vps", "client": "crescō",
"created": 1785922398, // de #{session_created}
"command": "claude", // de #{pane_current_command}
"state": "ask", // work · ask · wait · idle · dead
"since": 40, // segundos en este estado
"task": "office-adduser",
"signals": { "agents": "5/6", "tokens": "714k" },
"snapshot": "<span class=…>⏺</span> node-pty es…",
// solo si state === "ask"
"ask": {
"huella": "a3f9c1",
"prompt": "Instalar build-essential para compilar node-pty",
"options": [
{ "n": 1, "label": "Sí" },
{ "n": 2, "label": "Sí, y no preguntes más en esta sesión" },
{ "n": 3, "label": "No, dime qué harías distinto" }
]
}
}
Y la única ruta que puede negarse
POST /api/sessions/cresco/reply
→ { "text": "2", "huella": "a3f9c1" } ← 200 { "state": "work", "snapshot": "…" } // el eco, ya releído del pane ← 409 { "error": "stale", "ask": { … } } // la pregunta cambió · no se mandó nada
409 Conflict es literalmente lo que pasó: el estado del recurso cambió debajo de ti. La respuesta trae la pregunta nueva, así que la tarjeta se repinta con lo que hay ahora en vez de mandarte a recargar.
El archivo de las firmas
La promesa del capítulo del semáforo —«afinar el detector es editar una lista»— solo se sostiene si de verdad hay una lista. Es server/state-patterns.js, y exporta arrays, no lógica:
// cada array es una lista de firmas. Nada de ifs anidados: si un patrón // sobra o falta, se edita aquí y el clasificador no se toca. export const TRABAJANDO = [/esc to interrupt/, /[✻✽✢]/, /\d+\/\d+ agents/]; export const PREGUNTA = [/^\s*\d+\.\s/m, /\[y\/N\]/i, /Do you want/i]; export const ESPERANDO = [/^❯\s*$/m]; // y la única función con criterio: dónde empieza y acaba la pregunta. export function extractAsk(pane) // → { prompt, options, huella } | null
Lo que el diseño abrió y había que cerrar.
Tres huecos que no existían en el spec original y que aparecieron al aceptar que se puede responder desde la tarjeta y que el aviso te busca. Ninguno es un detalle de implementación: los tres deciden si la función sirve.
1 · La huella es del bloque de la pregunta, no del pane
Si la huella fuera un hash de la pantalla entera, un spinner girando la cambiaría cada 100 ms y la guardia rechazaría todas las respuestas. La función más cuidadosa del producto se volvería la más inútil, y por una razón invisible: el usuario vería «la pregunta cambió» sin que nada hubiera cambiado.
Por eso la huella se calcula sobre el bloque que extractAsk ya localizó —la línea del prompt más las opciones, normalizadas y sin timestamps— y no sobre el pane. Consecuencias, que son justo las que se quieren:
- Si Claude sigue preguntando lo mismo cinco minutos después, la huella es idéntica y tu respuesta entra.
- Si abajo se imprimió más salida pero la pregunta sigue viva, la huella aguanta — porque la pregunta sigue siendo la misma.
- Si la pregunta se contestó sola y hay otra, la huella cambia. Que es el único caso que debía bloquear.
Y se compara contra una captura fresca, no contra la del último poll. Si el servidor validara la huella contra su caché de 3 s, la guardia tendría una ventana ciega de exactamente ese tamaño — justo el escenario que existe para cerrar. La ruta reply hace capture-pane de nuevo, extrae, compara y manda en el mismo tirón. tmux no ofrece un compare-and-swap, así que la ventana no llega a cero; pero pasa de tres segundos a unos milisegundos, y esa diferencia es toda la función.
2 · Sí hace falta guardar estado — y sigue sin guardar tu trabajo
El spec original decía «sin persistencia» y era verdad entonces. El alcance nuevo lo rompe: hay cuatro cosas que tienen que sobrevivir a un systemctl --user restart.
| Qué persiste | Por qué no puede vivir en memoria |
|---|---|
| La suscripción de push | La da el navegador una sola vez. Perderla es dejar de avisar en silencio — el peor modo de fallo posible para esto. |
| Tus atajos | Los escribiste tú. Que se borren en un reinicio los vuelve inservibles. |
| Los silencios | «Callada hasta las 4» tiene que seguir siendo verdad después de un deploy. |
| La última transición avisada | Sin esto, un reinicio re-avisa de todo lo que ya está en ámbar. Un servicio que se reinicia solo te llenaría el teléfono. |
La forma: un solo archivo JSON en ~/.local/state/vigilo/state.json, escrito con write-and-rename atómico. Sin SQLite, sin base de datos, sin migraciones. Y la regla que mantiene el juramento intacto: se guarda lo que tú configuraste y lo que ya te avisé; nunca lo que dijo Claude. Ni una línea de pane toca el disco.
3 · Cómo entra la respuesta al pane
Aquí es donde «lo mandé» y «llegó» se separan de verdad, y hay dos trampas conocidas.
- El literal no es opcional.
tmux send-keysinterpreta su argumento como nombres de teclas: un texto que contenga la palabraEnteroC-cse convertiría en esa tecla. Va siempre con-l, que lo manda literal. Es el fallo silencioso clásico de esta API. - Una línea y varias líneas no viajan igual. Una línea va por
send-keys -l. Varias tienen que ir porload-buffer+paste-buffer -p, que sí envuelve en bracketed paste: sin eso, cada salto de línea se interpreta como envío y tu párrafo se convierte en cinco prompts.
# responder una opción: el menú de Claude responde a la tecla $ tmux send-keys -t cresco -l "2" # responder texto libre: literal + Enter aparte $ tmux send-keys -t work-mogos -l "continúa" && tmux send-keys -t work-mogos Enter # varias líneas: por buffer, con bracketed paste $ tmux load-buffer -b vigilo - && tmux paste-buffer -b vigilo -p -t work-mogos
A verificar contra el TUI real
Que el menú de permisos de Claude se resuelva con la tecla del número sin Enter es lo que se observa, pero no está documentado y puede cambiar entre versiones. Se verifica en la caja durante la fase 3, y si resulta que necesita Enter, la diferencia vive en una sola función — send(sesión, texto, esTecla).
Es la segunda cosa de esta pieza marcada así, junto con las cabeceras de identidad del tailnet. Las dos se comprueban antes de que nada dependa de ellas.
El ciclo de una sola transición.
«Un aviso por transición, no por estado» es una regla de producto; esto es la máquina que la cumple. Todo pasa dentro del mismo bucle de 3 s que ya alimenta la grilla — el aviso no tiene proceso propio.
listSessions() — una sola llamada a tmux — y el clasificador sobre cada snapshot.work o idle a ask o wait. Solo eso es una transición; quedarse en ámbar no lo es.state.json por sesión + huella. Si coincide, se corta aquí. Y como la clave incluye la huella, una pregunta nueva en la misma sesión sí vuelve a avisar — que es exactamente lo que quieres.now < hasta, se corta. El silencio caduca solo; no hay que acordarse de reactivarlo.tag: "<sesión>", que hace que la notificación nueva reemplace a la vieja de esa sesión en vez de apilarse. El body dice «cresco te espera» y nada más.notificationclick → clients.openWindow('/s/cresco'). Te deja dentro de esa sesión, no en la portada — si tocas un aviso y tienes que buscar de qué era, el aviso falló.Las llaves
Web Push se firma con un par VAPID, generado en la instalación: la privada queda en ~/.local/state/vigilo/vapid.json con permisos 0600, la pública se sirve al cliente para pushManager.subscribe(). No hay servicio de terceros de por medio: el proceso de tu caja firma y entrega directamente al endpoint que le dio el navegador.
El borde honesto del push
La notificación viaja por Apple o Google y llega aunque el teléfono no tenga el tailnet despierto. Pero el link no. Si tocas el aviso sin Tailscale activo, la PWA abre y muestra «sin tailnet» con el botón para activarlo — no un error de red del navegador.
Es la costura real de este diseño: el llamado viaja por internet, la respuesta solo por el tailnet. Y está bien que sea así — es la misma frontera que hace que esto no sea una shell en la web.
Qué sobrevive, y en qué orden.
La implementación anterior llegó a la tarea 4 de 8 antes de que abriéramos esta sesión de diseño. La mayor parte del motor sirve tal cual; lo que se rehace es la superficie.
Lo construido en claude-shared | estado | Qué pasa con este diseño |
|---|---|---|
1 · Parsers de tmux — listSessions() de una sola llamada | sobrevive | Entera. El formato ya trae path y command en la misma invocación, que es lo que hace barato el bucle de 3 s. |
| 2 · Clasificador de estado | se amplía | El motor y el archivo de firmas se quedan. Hay que partir el ámbar en dos y añadir extractAsk(), que no existía: es lo que hace posible responder sin entrar. |
| 3 · Snapshots y ANSI→HTML | sobrevive | Entera, con el fix del byte ESC que encontró el revisor ejecutando el algoritmo contra bytes reales. Ese bug habría filtrado bytes de control al HTML de cada tarjeta. |
| 4 · Puente node-pty | sobrevive | Entera. La sesión agrupada ya estaba resuelta y verificada contra la caja. |
| 5–8 — sin empezar | se rehacen | Se reescriben contra este diseño: cinco estados, superficie de respuesta, transporte HTTPS y aviso. |
Las seis fases
Cada una cierra con una comprobación que puede fallar de verdad. Una fase que cierra con «compila» no cierra nada.
tmux ls; una sesión esperando input sale en ámbar y una con trabajo en curso, en moss.shift+tab y /comandos desde el navegador y llega — y tmux display -p '#{window_width}' de tu sesión de Termius devuelve lo mismo antes y después.extractAsk(), la huella, la ruta reply, el 409 y el eco releído del pane.tailscale serve, la unidad systemd --user, el instalador y la resolución de la identidad del tailnet.https://office.<tailnet>.ts.net responde, sigue en pie tras un reboot, y un curl desde fuera del tailnet no llega.systemctl --user restart vigilo y las dos cosas siguen siendo verdad.Lo que pasa cuando algo sale mal.
| El borde | Qué hace vigilō |
|---|---|
| La sesión muere mientras la miras | La terminal lo dice y te devuelve a la grilla en tres segundos. No se queda un cadáver en pantalla fingiendo estar vivo. |
| El WebSocket se cae — cambias de red, duermes el Mac | Reconexión con backoff. tmux nunca perdió nada: al volver ves el estado actual, no el que dejaste. |
| La red mala como estado normal | La grilla se degrada a la última foto buena con su hora visible. Un dato viejo etiquetado es honesto; un dato viejo disfrazado de fresco, no. |
| Tu Mac se apaga | Nada se pierde. Es el mismo escenario que ya vive la office hoy: el trabajo es de la caja, no de tu laptop. |
| El primer día, sin sesiones | El vacío no dice «no hay datos»: te ofrece crear la primera, reusando los launchers que ya existen en la caja. |
| Matar una sesión | Confirmación explícita con el nombre a la vista. Es destructivo y se comporta como tal. |
Lo que vigilō no es.
Un producto se define tanto por lo que se niega a hacer. Estas cuatro no son omisiones pendientes: son el límite.
- No es un IDE. No edita archivos, no navega el repo, no abre diffs. Vigila sesiones.
- No reemplaza a Termius. Cuando te sientas de verdad a trabajar, te sientas de verdad. vigilō es para los otros momentos — que son la mayoría.
- No lee el trabajo de nadie más. Un servicio por usuario, y punto.
- No guarda historial del contenido de tus sesiones. Ni siquiera el suyo propio.
Diez reglas que no se negocian.
- Nada que te espere se queda callado, y nada que te llame exige que te sientes. Media ley no es ley.
- Los botones son sus opciones, leídas del pane. Nunca un menú nuestro encima del suyo.
- Ninguna respuesta se manda sobre una pregunta que cambió. Si la huella no coincide, no sale nada.
- Se muestra el eco, no la intención. Lo que ves es lo que llegó al pane.
- Un aviso por transición, no por estado. Avisar de más es enseñarte a ignorar el aviso.
- El aviso lleva el llamado, no el contenido. Lo que sale del tailnet es una palabra, no tu trabajo.
- El dato que no existe no se inventa. Sin cliente conocido no hay chip; sin foto fresca, la hora a la vista.
- Abrir vigilō no cambia el tamaño de la ventana de nadie. Mirar no puede tener efectos secundarios.
- El color nunca carga solo el significado. Siempre con forma, etiqueta y lo que puedes hacer.
- Se dice lo que es. La detección es heurística, el token es un cerrojo, y esto entrega una shell.