Conceptos

Sentinel

El proceso en segundo plano que despierta al commander solo cuando un soldier necesita atención.

El sentinel es lo que te deja alejarte o trabajar en varias tareas a la vez. Vigila a cada soldier despachado e interrumpe al commander (y por lo tanto a vos) solo cuando uno termina.

Qué hace

Corre un sentinel por máquina. Cada 5 segundos consulta el estado en vivo de herdr de cada tarea en running, en todos los proyectos que vexillum haya visto. Cuando una tarea termina (done o blocked) persiste el nuevo status y escribe un wake durable (wakes/<task-id>.json) en la cola del commander que despachó la tarea: su propio proyecto cuando el commander trabaja ahí, el proyecto del commander cuando despachó desde otro (mirá Un commander en otro proyecto). También detecta tareas que pasaron a interrupted o unconfirmed, y solo después de confirmarlo durante una ventana, no por una sola observación. Para esas también despierta al commander.

También vigila a los soldiers ya terminados cuyo camp sigue en su lugar: si uno vuelve a trabajar (le mandaron un prompt después de terminar), el sentinel devuelve su tarea a running sin un wake, así que su próximo fin es una transición como cualquier otra. Ver Dar seguimiento a un soldier terminado.

vx dispatch inicia un sentinel automáticamente si no hay uno corriendo. Si eso falla avisa por stderr, y podés correr vx sentinel vos mismo en segundo plano. Un segundo sentinel se niega a arrancar y nombra el pid del primero: el reclamo es un lock que el kernel suelta cuando el proceso muere, así que un crash nunca deja uno viejo colgado y dos sentinels no pueden correr a la vez. Su pid, el build desde el que corre y su log están en ~/.vexillum/sentinel.pid, ~/.vexillum/sentinel.json y ~/.vexillum/sentinel.log.

Un wake se entrega como mucho una vez y solo mientras siga siendo noticia. Al leerlo, el sentinel lo descarta si el status de la tarea cambió desde entonces (la respondiste, le mandaste otro prompt o la hiciste ship), si a su camp ya se le hizo strike o si su mission ya aterrizó, así que una acumulación de wakes mientras nadie escuchaba nunca se reproduce como una ráfaga de avisos viejos. Corré vx status para ver el panorama completo después de una ausencia larga.

Un wake además se consume solo una vez entregado. await y drain lo reclaman, lo imprimen y lo borran únicamente después de que la impresión salió bien. Si el hook que lanzó el await ya no está (el proceso que lo inició salió, recibió una señal, o no se puede escribir en stderr) el wake se vuelve a poner para el siguiente await en lugar de imprimirse al vacío, y un reclamo cuyo dueño murió se encuentra de nuevo a los dos minutos. Lo único que un hook de Claude Code no puede hacer es esperar para siempre: un await se rinde a los 55 minutos, dentro del timeout de una hora con el que se registra el hook, así que un commander de Claude Code inactivo por más tiempo no oye nada hasta su próximo turno (un commander Pi arranca solo un await nuevo cuando uno vence, y uno OpenCode no tiene ninguno). El wake sigue en disco, vx status muestra qué pasó y vx doctor advierte sobre los wakes que esperan en un proyecto sin await vivo ni commander registrado vivo.

El mismo hook también lleva forum wakes: cuando un usuario manda feedback desde un panel de revisión de forum, el listener de forum (no un soldier) lo guarda en el inbox del proyecto y registra un wake bajo wakes/forum/, uno por sesión. vx sentinel await y drain lo informan como forum session <file>: 2 new messages (ended: false). Run vx forum inbox. Se entrega una vez y se descarta si sus mensajes ya fueron confirmados; la fuente de verdad es el inbox, no el wake.

Herdr informa un turno como idle en el momento en que el agente se detiene, y el panel todavía puede estar pintando el final de su último mensaje. Antes de registrar a un soldier como done (o blocked), el sentinel lee el panel hasta que dos lecturas separadas por un segundo coinciden, así que la salida guardada con el wake contiene el reporte final del soldier. Un panel que nunca se estabiliza solo demora el wake unos segundos; la tarea queda entonces como done con una nota al final de su output que dice que el reporte final puede faltar, y vx prompt puede pedírselo al soldier. La misma nota aparece cuando el panel no se puede leer en absoluto.

Un turno idle que no está probado como terminado no es final. Herdr puede leer un pane como idle en medio de una herramienta larga, como una suite de tests que no imprime nada durante minutos, y un turno puede terminar con N ... still running en su último resumen (por ejemplo 1 shell still running) mientras el soldier espera trabajo en segundo plano. Cuando el pane todavía muestra un turno en curso (una línea de spinner con un temporizador, una línea de herramienta Running...), o el resumen dice que hay trabajo corriendo, el sentinel mantiene la tarea en running sin un wake, aunque ya tenga un commit o un reporte. A los 30 minutos de eso deja de esperar y marca la tarea como unconfirmed, no done, con una nota al final de su output que dice que el reporte puede no existir. Si el soldier termina después, la tarea vuelve a running y se asienta con normalidad.

Un wake unconfirmed se explica solo. Su nota dice si el soldier había declarado una pausa que ya no está (despertó, borró su archivo de pausa y terminó su turno sin entregar), que el pane está idle y que no hay commit ni reporte, y el output de la tarea trae la lectura del pane tomada en ese momento. Para esto el sentinel recuerda una pausa declarada en el pause_seen_at de la tarea, incluso mientras retiene la tarea por trabajo en segundo plano. El commander no confía en un herdr pane read, que puede mostrar un viewport viejo, para decidir si un soldier así sigue trabajando.

Un soldier solo queda registrado como blocked por una línea needs-decision: que escribió en su último turno, después del final del prompt despachado, con texto real. La línea de plantilla del prompt, cualquier placeholder entre ángulos y un valor que dice que no hay nada que decidir (needs-decision: none, n/a, una línea vacía) nunca cuentan, ni tampoco una pregunta que ya respondiste o descartaste.

Mantenerse al día

Un sentinel es un proceso de larga vida, así que puede sobrevivir al binario con el que arrancó. Uno desactualizado seguiría reescribiendo el estado de las tareas con lógica vieja. Dos cosas lo evitan:

  • Las tareas conservan lo que no entienden. Guardar una tarea cambia solo los campos que el build en ejecución conoce y vuelve a escribir tal cual cualquier otro campo de primer nivel, así que un proceso desactualizado no puede borrar lo que registró uno más nuevo.
  • El sentinel se retira cuando cambia su binario. Registra el build desde el que arrancó (versión y archivo ejecutable) en ~/.vexillum/sentinel.json y lo revisa después de cada pasada. Cuando ese archivo fue reemplazado en disco (un rebuild, brew upgrade) termina la pasada en curso, suelta su lock y sale. Compara el archivo y no solo la versión, así que un rebuild siempre se detecta.

El siguiente vx dispatch, vx prompt o await del sentinel arranca entonces un sentinel nuevo desde el binario nuevo, y el reemplazo espera un momento a que el viejo suelte el lock en vez de rendirse. Un await lo verifica en cada consulta, así que una sesión de commander que solo está esperando recibe un sentinel nuevo en segundos.

vx doctor avisa cuando hay más de un proceso sentinel vivo, cuando el sentinel vivo es de otra versión que el binario que corre doctor, o cuando su ejecutable cambió desde que arrancó. Cada aviso nombra el pid que hay que detener con kill, y el siguiente dispatch arranca uno al día. Doctor nunca detiene un proceso por su cuenta.

Cómo se despierta al commander

vx init registra un hook Stop asíncrono en el .claude/settings.json del proyecto:

{
  "type": "command",
  "command": "sh -c 'for v in \"$(command -v vx 2>/dev/null)\" \"$HOME/.local/bin/vx\" /opt/homebrew/bin/vx /usr/local/bin/vx /home/linuxbrew/.linuxbrew/bin/vx; do [ -f \"$v\" ] && [ -x \"$v\" ] && exec \"$v\" sentinel await; done; echo \"vx: not found, so the sentinel Stop hook cannot wake the commander. Install vx (brew or curl) or put its directory on PATH, then run: vx doctor\" >&2; exit 0'"
}

registrado con asyncRewake y un timeout de una hora. El comando es un wrapper POSIX sh -c de una línea que termina corriendo vx sentinel await; ver El hook no depende de tu PATH. await se bloquea, revisando cada 5 segundos, hasta que llega un wake para este proyecto, y entonces responde {"decision":"block","reason":...} para que Claude Code reporte el cambio en lugar de terminar su turno en silencio - incluso si el turno terminó antes de que el soldier acabara. El reason lista cada cambio, por ejemplo - mission <task-id>: running -> done.

SubcomandoComportamiento
vx sentinelconsulta para siempre (corrélo en segundo plano)
vx sentinel awaitse bloquea hasta que llega un wake o se agota el tiempo; lo que llama el hook
vx sentinel drainrevisa una vez, al instante: una decisión block si hay algo pendiente, {} si no

Fuera de un pane administrado por herdr (sin HERDR_WORKSPACE_ID), await y drain salen en silencio: el hook se commitea con el proyecto, así que también se dispara en turnos de Claude Code ajenos, como la revisión headless de un camp, que no tienen ningún soldier que esperar.

Cada await se registra en ~/.vexillum/sentinel-awaiters/ y nunca sobrevive al hook que lo lanzó. Sale solo cuando su proceso padre desaparece, y un await más nuevo de la misma sesión y proyecto reemplaza al del turno anterior, así que una sesión deja un proceso esperando, no uno por turno. Arrancar vx sentinel o cualquier await también detiene a los huérfanos que dejó una sesión muerta. A un proceso solo se le manda una señal después de verificar, por su archivo de pid y su línea de comando, que es un vx sentinel await, nunca por nombre.

Un commander en Pi

Pi no tiene un hook Stop. Un proyecto cuyo commander corre en Pi recibe en su lugar .pi/extensions/vexillum-wake.ts, que arranca el mismo vx sentinel await como hijo de Pi y, cuando sale con código 2, le entrega su mensaje al modelo con pi.sendUserMessage (como follow-up cuando Pi está a mitad de turno). Todo lo anterior vale sin cambios, porque es el mismo await: una entrega por wake, un dueño que lo baja junto con Pi, el sentinel arrancado y refrescado por él. La extensión además guarda un wake que Pi no pudo recibir en ese momento y vuelve a intentar, y no hace nada en un camp.

Un commander en otro proyecto

Un commander no tiene que trabajar en el repositorio en el que despacha. Digamos que el commander corre en ~/notes y despacha soldiers en ~/shop. Las tareas de esos soldiers viven en el estado del proyecto shop, pero el hook Stop que despierta al commander corre en notes y drena solo la cola de notes, así que un wake escrito bajo shop nunca se leería.

Por eso la tarea registra qué commander la despachó, en commander_project (la raíz de proyecto que drena su hook) y commander_pane (su pane de herdr). vx dispatch, vx redispatch, vx prompt y vx decide los fijan a partir del pane en el que corren (HERDR_PANE_ID): el proyecto del vx sentinel await vivo de ese pane, que es exactamente la cola que lee su hook, o, antes de que haya terminado algún turno y por lo tanto antes de que exista un await, el proyecto en el que trabaja el agente del pane según herdr agent get. El sentinel escribe entonces el wake en la cola de ese proyecto, con el proyecto propio de la tarea en su campo origin para poder seguir juzgando si el wake es noticia. Nada más cambia: el await del commander, o el injector en OpenCode, lo entrega como cualquier otro wake. prompt y decide actualizan el registro, así que el commander que manda un seguimiento es el que se despierta cuando el soldier termina de nuevo.

El commander también queda registrado en el registro de commanders de su propio proyecto, que es lo que permite al sentinel encontrar ahí a un commander Pi u OpenCode. vx dispatch advierte cuando el proyecto del commander no es el que está despachando, porque status, land, strike y prompt se siguen corriendo desde el proyecto en el que se despachó el soldier. Fuera de un pane de herdr no se registra nada y el wake queda con la tarea, como antes. Las tareas despachadas por una versión anterior no tienen commander y siguen entregando en su propio proyecto.

Un commander en OpenCode

Un commander OpenCode 2.x no tiene hook Stop, así que entrega el propio sentinel: después de registrar un wake para un proyecto que tiene un commander OpenCode registrado y vivo (mirá el registro de commanders) y ningún await vivo, postea el mismo texto, una sola vez y bajo las mismas reglas de descarte de lo viejo, en la sesión del commander a través de la API HTTP de OpenCode (POST /api/session/<id>/prompt, encolado detrás del turno que corre, sin tocar el editor). Un wake que no puede entregar (sin sesión todavía, el servicio inalcanzable) se vuelve a poner y se reintenta, nunca se descarta, y después de 30 segundos fallando cae a herdr agent prompt cuando el commander está idle, lo que puede pegar el wake a un borrador sin enviar. Mirá OpenCode como commander.

Dos commanders en un proyecto

Un proyecto tiene una sola cola de wakes, y un wake lo consume el primer lector que lo reclama. Todos los commanders del proyecto, sea cual sea su harness, leen el mismo directorio wakes/ (su await en Claude Code y Pi, el injector del sentinel en OpenCode). Si corrés dos commanders en el mismo proyecto, por ejemplo uno en Claude Code y otro en Pi, un wake llega a uno solo y el otro nunca se entera de ese soldier ni de ese mensaje de forum. Un commander OpenCode junto a uno de Claude Code o Pi es el que peor queda: el sentinel inyecta solo cuando no hay ningún await vivo, así que no recibe nada.

Es una limitación conocida, y no hay que rodearla a mano. Se deja así a propósito. Un wake es un timbre para que un commander actúe (aterrizar, hacer strike, responder), así que entregarlo a dos haría que ambos actúen sobre el mismo soldier. Darle a cada commander su propio cursor además exigiría que cada await diga a qué commander sirve, cosa que el comando compartido del hook no hace, y una regla para cuándo se descarta un wake si un commander nunca lo lee. Son piezas móviles para una configuración a la que el modelo de commander no apunta.

Mantené un commander por proyecto. Si corrés dos, el que perdió un wake puede ponerse al día desde el disco, porque no se pierde nada: vx status muestra todas las tareas y vx forum inbox el feedback que espera. vx doctor advierte cuando el registro de commanders tiene dos commanders vivos para el proyecto:

[warn] commanders share one wake queue - 2 commanders are live in this project (claude, pi) and a wake is delivered once, to whichever asks first, ...

El hook no depende de tu PATH

Claude Code corre el hook en un shell que puede no haber leído nunca tu perfil, así que un vx sentinel await pelado puede fallar con command not found cuando vx vive en ~/.local/bin. Entonces el commander nunca se despertaría, sin que nada lo avise. Por eso el comando registrado busca vx por su cuenta, en este orden:

  1. command -v vx, el primer vx en el PATH del shell del hook;
  2. $HOME/.local/bin/vx (el instalador con curl);
  3. /opt/homebrew/bin/vx (Homebrew en Apple silicon);
  4. /usr/local/bin/vx (Homebrew en macOS Intel, o una instalación manual);
  5. /home/linuxbrew/.linuxbrew/bin/vx (Homebrew en Linux).

El primero que existe y es ejecutable se corre con exec, así que el proceso await sigue siendo hijo directo de quien lanza el hook. No se escribe ninguna ruta absoluta en settings.json, que se commitea y se comparte entre máquinas, ni se agrega ningún archivo extra a tu proyecto.

Si no encuentra ninguno, el hook no se queda callado: imprime una línea por stderr, vx: not found, so the sentinel Stop hook cannot wake the commander. Install vx (brew or curl) or put its directory on PATH, then run: vx doctor, y sale con 0 para no bloquear a Claude Code.

vx doctor lo verifica de verdad. Corre el comando registrado desde un entorno mínimo (env -i con solo HOME y PATH=/usr/bin:/bin) y reporta si encontró un vx, si el hook está registrado y si hay un sentinel corriendo:

[ok] sentinel Stop hook - registered in .claude/settings.json
[ok] sentinel Stop hook finds vx - found with a minimal environment
[ok] sentinel - running, 1 sentinel await(s) waiting

Un hook que no encuentra vx es un aviso (warn), no un fallo, y la línea nombra el arreglo: instalá vx donde el hook busca (~/.local/bin o Homebrew) y corré vx upgrade para reescribir el hook.

vx init y vx upgrade mantienen el hook al día. Preguntan primero, como el resto de upgrade, y cualquier hook Stop tuyo que no sea de vexillum se conserva.

Dar seguimiento a un soldier terminado

El sentinel reporta una tarea que sale de running, así que un soldier al que se le manda otro prompt después de terminar necesita que su tarea se reabra primero. Usá vx prompt:

vx prompt <task-id> "agregá también una entrada al changelog"

Devuelve la tarea a running, entrega el texto y se asegura de que haya un sentinel vigilando, así que el próximo fin del soldier vuelve a despertar al commander. Solo se puede mandar un prompt a una tarea done, shipped o unconfirmed a cuyo camp todavía no se le hizo strike (una shipped pasa a running y se asienta como done, no shipped, porque los commits nuevos no están en su pull request hasta que vx ship corra de nuevo); una tarea blocked se responde con vx decide y una interrupted con vx redispatch.

Mandarle el prompt al pane a mano (herdr agent prompt) sigue funcionando como respaldo: el sentinel reabre una tarea terminada que ve trabajando otra vez. Esa detección es por polling, así que un seguimiento muy corto que termina entre dos polls puede escapársele, por eso vx prompt es el camino soportado.

El sentinel sondea el panel antes de reabrir, y solo reabre una tarea que sigue terminada al guardar, así que una tarea que vx land o vx strike cerró mientras tanto sigue cerrada.

Ver la referencia de sentinel para el uso completo.

En esta página