Concepts

Sentinel

The background process that wakes the commander only when a soldier needs attention.

The sentinel is what lets you walk away or work on several tasks at once. It watches every dispatched soldier and interrupts the commander (and so you) only when one settles.

What it does

One sentinel runs per machine. Every 5 seconds it polls the live herdr status of every task in running, across every project vexillum has seen. When a task settles (done or blocked) it persists the new status and writes a durable wake (wakes/<task-id>.json) in the queue of the commander that dispatched the task: its own project when the commander works there, the commander's project when it dispatched from another one (see A commander in another project). It also notices tasks that became interrupted or unconfirmed, only after confirming over a window rather than on one observation, and wakes the commander for those too.

It also watches settled soldiers whose camp is still in place: if one is working again (it was prompted after it finished), the sentinel puts its task back to running without a wake, so its next finish is a transition like any other. See Following up on a finished soldier.

vx dispatch starts a sentinel automatically if none is running. If that fails it warns on stderr, and you can run vx sentinel yourself in the background. A second one refuses to start and names the pid of the first: the claim is a lock the kernel drops when the process dies, so a crash never leaves a stale one behind and two sentinels cannot run at once. Its pid, the build it runs from and its log live in ~/.vexillum/sentinel.pid, ~/.vexillum/sentinel.json and ~/.vexillum/sentinel.log.

A wake is delivered at most once and only while it is still news. When it is read, the sentinel drops it if the task's status has changed since (you answered, re-prompted or shipped it), its camp was struck, or its mission was landed, so a backlog that built up while nobody was listening never replays as a burst of old notices. Run vx status for the full picture after a long absence.

A wake is also consumed only once it has been delivered. await and drain claim it, print it, and remove it only after the print succeeded. If the hook that launched the await is already gone (the process that started it exited, it was signaled, or stderr cannot be written) the wake is put back for the next await instead of being printed into nothing, and a claim whose holder died is found again after two minutes. The one thing a Claude Code hook cannot do is wait forever: an await gives up after 55 minutes, inside the one-hour timeout the hook is registered with, so a Claude Code commander idle for longer hears nothing until its next turn (a Pi commander starts a new await by itself when one times out, and an OpenCode commander has none). The wake is still on disk, vx status shows what happened, and vx doctor warns about wakes that sit in a project with no live await and no live registered commander.

The same hook also carries forum wakes: when a user sends feedback from a forum review panel, the forum listener (not a soldier) stores it in the project's inbox and records a wake under wakes/forum/, one per session. vx sentinel await and drain report it as forum session <file>: 2 new messages (ended: false). Run vx forum inbox. It is delivered once and dropped if its messages were already confirmed; the inbox, not the wake, is the source of truth.

Herdr reports a turn idle the moment the agent stops, and the pane can still be painting the end of its last message. Before it records a soldier as done (or blocked), the sentinel reads the pane until two reads a second apart agree, so the output saved with the wake holds the soldier's final report. A pane that never holds still delays the wake by a few seconds only; the task is then marked done with a note at the end of its output saying the final report may be missing, and vx prompt can ask the soldier for it. The same note appears when the pane cannot be read at all.

An idle turn that is not provably over is not final. Herdr can read a pane as idle in the middle of a long tool call, such as a test suite that prints nothing for minutes, and a turn can end with N ... still running in its last summary (for example 1 shell still running) while the soldier waits for background work. When the pane still paints a turn in progress (a spinner line with a timer, a Running... tool line), or the summary says work is still running, the sentinel holds the task running with no wake, whatever commit or report it already has. After 30 minutes of that it stops waiting and marks the task unconfirmed, not done, with a note at the end of its output saying the report may not exist. If the soldier finishes later, the task goes back to running and settles normally.

An unconfirmed wake explains itself. Its note says whether the soldier had declared a pause that is now gone (it woke, removed its pause file and ended its turn without delivering), that the pane is idle, and that no commit or report exists, and the task's output holds the pane read taken at that moment. The sentinel remembers a declared pause in the task's pause_seen_at for this, even while it holds the task for background work. The commander does not rely on a herdr pane read, which can show a stale viewport, to decide whether such a soldier is still working.

A soldier is only recorded as blocked on a needs-decision: line it wrote in its final turn, after the end of the dispatched prompt, with real text. The template line in the prompt, any angle-bracket placeholder and a value that says there is nothing to decide (needs-decision: none, n/a, an empty line) never count, and neither does a question you already answered or dismissed.

Staying current

A sentinel is a long-lived process, so it can outlive the binary it started from. A stale one would keep rewriting task state with outdated logic. Two things prevent that:

  • Tasks keep what they do not understand. Saving a task changes only the fields the running build owns and writes every other top-level field back exactly as it found it, so a stale process cannot erase what a newer one recorded.
  • The sentinel retires when its binary changes. It records the build it started from (version and executable file) in ~/.vexillum/sentinel.json and checks it after every pass. Once that file has been replaced on disk (a rebuild, brew upgrade) it finishes the pass it is in, releases its lock and exits. It compares the file, not just the version, so a rebuild is always noticed.

The next vx dispatch, vx prompt or sentinel await then starts a fresh sentinel from the new binary, and the replacement waits briefly for the old one to let go instead of giving up. An await checks on every poll, so a commander session that is only waiting gets a new sentinel within seconds.

vx doctor warns when more than one sentinel process is alive, when the live sentinel is a different version than the binary running doctor, or when its executable has changed since it started. Each warning names the pid to stop with kill, and the next dispatch starts a current one. Doctor never stops a process itself.

How the commander is woken

vx init registers an async Stop hook in the project's .claude/settings.json:

{
  "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'"
}

registered with asyncRewake and a one-hour timeout. The command is a one-line POSIX sh -c wrapper that ends up running vx sentinel await; see The hook does not depend on your PATH. await blocks, re-checking every 5 seconds, until a wake for this project arrives, then answers {"decision":"block","reason":...} so Claude Code reports the change instead of quietly ending its turn - even if the turn ended before the soldier settled. The reason lists each change, for example - mission <task-id>: running -> done.

SubcommandBehavior
vx sentinelpoll forever (run it in the background)
vx sentinel awaitblock until a wake arrives or time runs out; what the hook calls
vx sentinel draincheck once, instantly: a block decision if something is pending, {} otherwise

Outside a herdr-managed pane (no HERDR_WORKSPACE_ID), await and drain exit silently: the hook is committed with the project, so it also fires in unrelated Claude Code turns, such as a camp's own headless review, which have no soldier to wait for.

Each await records itself in ~/.vexillum/sentinel-awaiters/ and never outlives the hook that launched it. It exits on its own once its parent process is gone, and a newer await from the same session and project replaces the previous turn's, so one session leaves one waiting process, not one per turn. Starting vx sentinel or any await also stops orphans left behind by a dead session. A process is signaled only after it is verified to be a vx sentinel await by its pid file and command line, never by name.

A commander in Pi

Pi has no Stop hook. A project whose commander runs in Pi gets .pi/extensions/vexillum-wake.ts instead, which starts the same vx sentinel await as a child of Pi and, when it exits with code 2, hands its message to the model with pi.sendUserMessage (as a follow-up when Pi is mid-turn). Everything above holds unchanged, because it is the same await: one delivery per wake, an owner that takes it down with Pi, the sentinel started and refreshed by it. The extension also keeps a wake that Pi could not take at that moment and tries again, and does nothing in a camp.

A commander in another project

A commander does not have to work in the repository it dispatches into. Say the commander runs in ~/notes and dispatches soldiers into ~/shop. The soldiers' tasks live in shop's project state, but the Stop hook that wakes the commander runs in notes and drains only notes' queue, so a wake written under shop would never be read.

So the task records which commander dispatched it, in commander_project (the project root its hook drains) and commander_pane (its herdr pane). vx dispatch, vx redispatch, vx prompt and vx decide set them from the pane they run in (HERDR_PANE_ID): the project of that pane's live vx sentinel await, which is exactly the queue its hook reads, or, before any turn has ended and so before any await exists, the project the pane's agent works in according to herdr agent get. The sentinel then writes the wake into that project's queue, with the task's own project in its origin field so it can still tell whether the wake is news. Nothing else changes: the commander's await, or the injector for OpenCode, delivers it like any other wake. prompt and decide update the record, so the commander that sends a follow-up is the one woken when the soldier settles again.

The commander is also registered in its own project's commander registry, which is what lets the sentinel find a Pi or OpenCode commander there. vx dispatch warns when the commander's project is not the one it dispatches into, because status, land, strike and prompt still run from the project the soldier was dispatched into. Outside a herdr pane nothing is recorded and the wake stays with the task, as before. Tasks dispatched by an older version carry no commander and keep delivering to their own project.

A commander in OpenCode

An OpenCode 2.x commander has no Stop hook, so the sentinel itself delivers: after it records a wake for a project that has a live registered OpenCode commander (see the commander registry) and no live await, it posts the same text, once and under the same stale-drop rules, into the commander's session through OpenCode's HTTP API (POST /api/session/<id>/prompt, queued behind the running turn, without touching the editor). A wake it cannot deliver (no session yet, the service unreachable) is put back and retried, never dropped, and after 30 seconds of failing it falls back to herdr agent prompt when the commander is idle, which can glue the wake onto an unsent draft. See OpenCode as the commander.

Two commanders in one project

A project has one wake queue, and a wake is consumed by whichever reader claims it first. Every commander of the project, whatever its harness, reads the same wakes/ directory (its await for Claude Code and Pi, the sentinel's injector for OpenCode). If you run two commanders in the same project, for example one in Claude Code and one in Pi, a wake reaches only one of them and the other never hears about that soldier or that forum message. An OpenCode commander next to a Claude Code or Pi one is the worst off: the sentinel injects only when no await is alive, so it receives nothing.

This is a known limitation, not something to work around by hand. It is left as it is on purpose. A wake is a doorbell for one commander to act on (land, strike, answer), so delivering it to two would make both act on the same soldier. Giving each commander its own cursor would also need every await to say which commander it serves, which the shared hook command does not, and a rule for when a wake is dropped if one commander never reads it. Both are moving parts for a setup the commander model does not aim at.

Keep one commander per project. If you do run two, the one that missed a wake can catch up from disk, since nothing is lost: vx status shows every task and vx forum inbox the waiting feedback. vx doctor warns when the commander registry holds two live commanders for the project:

[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, ...

The hook does not depend on your PATH

Claude Code runs the hook in a shell that may never have read your profile, so a bare vx sentinel await can fail with command not found when vx lives in ~/.local/bin. The commander would then never be woken, with nothing to say so. The registered command therefore looks for vx itself, in this order:

  1. command -v vx, the first vx on the hook shell's PATH;
  2. $HOME/.local/bin/vx (the curl installer);
  3. /opt/homebrew/bin/vx (Homebrew on Apple silicon);
  4. /usr/local/bin/vx (Homebrew on Intel macOS, or a manual install);
  5. /home/linuxbrew/.linuxbrew/bin/vx (Homebrew on Linux).

The first one that exists and is executable is run with exec, so the await process stays a direct child of the hook runner. No absolute path is written into settings.json, which is committed and shared across machines, and no extra file is added to your project.

If none is found the hook does not stay silent: it prints one line to 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, and exits 0 so Claude Code is not blocked.

vx doctor checks this for real. It runs the registered command from a bare environment (env -i with only HOME and PATH=/usr/bin:/bin) and reports whether it found a vx, whether the hook is registered at all, and whether a sentinel is running:

[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

A hook that cannot find vx is a warning, not a failure, and the line names the fix: install vx where the hook looks (~/.local/bin or Homebrew) and run vx upgrade to rewrite the hook.

vx init and vx upgrade keep the hook up to date. They ask first like the rest of upgrade, and any Stop hook of yours that is not vexillum's is kept.

Following up on a finished soldier

The sentinel reports a task leaving running, so a soldier prompted again after it finished needs its task reopened first. Use vx prompt:

vx prompt <task-id> "also add a changelog entry"

It sets the task back to running, delivers the text, and makes sure a sentinel is watching, so the soldier's next finish wakes the commander again. Only a done, shipped or unconfirmed task whose camp has not been struck can be prompted (a shipped one goes to running, and settles as done, not shipped, since the new commits are not on its pull request until vx ship runs again); a blocked task is answered with vx decide, and an interrupted one with vx redispatch.

Prompting the pane by hand (herdr agent prompt) still works as a fallback: the sentinel reopens a settled task it sees working again. That detection is poll-based, so a very short follow-up that finishes between two polls can slip past it, which is why vx prompt is the supported path.

The sentinel probes the pane before reopening, and only reopens a task that is still settled when it saves, so a task that vx land or vx strike closed in the meantime stays closed.

See the sentinel reference for the full usage.

On this page