-
Notifications
You must be signed in to change notification settings - Fork 0
The stop hook
Describes robot-council/cli v0.4.28.
The bridge follows the fleet's change feed while an agent works and leaves whatever concerns that
session in a sink. robot-council pending prints what is waiting and clears it, and a harness's
stop hook is what calls it -- so the moment an agent would otherwise go idle, it picks up the
directive or the handed-back task instead.
The hook delivers; it does not wake. On its own it runs only when a turn ends, so an agent that
stopped an hour ago stays stopped until something starts a turn. In Claude Code, the bridge can
start one: when events arrive for an idle session, it sends a channel notice, and the turn that
notice starts ends by running this hook (since v0.4.0,
#197). That needs Claude Code started with
--dangerously-load-development-channels server:robot-council, and without it nothing reports that
the seat cannot be woken. The wiki's
Starting a seat so it can be woken
has the invocation, its requirements, and how to confirm it registered. Cursor and Codex have no such
notice, so for them the hook delivers at the next turn the operator starts.
Without a stop hook, a woken Claude Code agent is told to read the feed itself
(#306). The notice only wakes the agent, and
the hook is what delivers. On 2026-09-25, six seats with channels on and no Stop hook each woke
within a second of a placement, ended the turn as instructed, and saw nothing for over forty minutes.
So under Claude Code, the bridge checks at start for a Stop hook in the settings Claude Code reads
for the launch folder: settings.json and settings.local.json in ~/.claude (or
CLAUDE_CONFIG_DIR), and in the project's .claude. A hook counts when its type is command,
whatever it runs. disableAllHooks is taken from the most specific file that sets it, as Claude
Code does. Managed (enterprise) settings are not read.
If no hook will run, a notice tells the agent to call events_read and act on what concerns it,
rather than to end the turn. The bridge stops repeating that notice once the agent has read the
feed, since with no hook nothing else would ever settle it. Either way, the bridge says which case
applied once, on stderr, which Claude Code keeps as the server's log:
robot-council: stop hook: found in <file>. or
robot-council: stop hook: none found; an agent woken by a notice is told to read the feed itself.
A settings file that is not valid JSON is named there and treated as having no hook. Reading the
feed that way works, but the hook is still worth installing, because it hands over events at every
turn end and not only when a notice wakes the agent.
A hook that always continues the turn is a session that never stops. robot-council pending
prints nothing and exits 0 when the fleet has been quiet, and the script below ends the turn on empty
output. That one line is the whole difference between a hook and a loop.
Without a session in the coordinator role, almost nothing arrives. A directive is the only
event that reaches a waiting session whatever it concerns -- every session when it names no
targets, and only the sessions it names when it does
(#269) -- and posting one needs
coordinator:direct, which enrollment can never ask for and which comes with the coordinator role
an administrator gives a running session from the fleet's administration page. Reassigning a task,
cancelling one, and forcing a lock open need it too. What still arrives without one: a lock taken
over from this session, which needs only locks:acquire, and this session's own stale marking. A
task claim always assigns to whoever claimed it. Narration reaches a sink only when it is addressed
to that session, through meta.to or a task it holds in meta.to_tasks, which is how a coordinator
answers a seat (#313); any other narration
reaches no sink at all. Decided on #112, where
the alternatives are recorded.
So a machine can be enrolled, its bridge following the feed, its sink written and its stop hook
wired into every harness, and still receive no directive at all -- because no session on that fleet
is running in the coordinator role. Every part reports healthy and the symptom is silence,
which is indistinguishable from a fleet that genuinely has nothing to say. Wiring the hook up is
worth doing anyway; it costs nothing while the fleet is quiet. Just know which of the two you are
looking at.
A session does not need the ability to receive. Holding none of it is the ordinary, correct state for a machine that only listens, and it is what enrollment grants. The ability is needed by whoever sends.
One script serves all three harnesses. Fill in the two values at the top and give the path to whichever configuration matches the harness: Claude Code, Cursor, or Codex below.
The script is resources/stop-hook/robot-council-stop-hook, committed and tested by tests/Feature/StopHookScriptTest.php rather than printed here, so the copy you download is the copy the suite runs (#318). Download it:
curl -fsSL -o ~/bin/robot-council-stop-hook \
https://raw.githubusercontent.com/robot-council/cli/v0.4.28/resources/stop-hook/robot-council-stop-hook
chmod +x ~/bin/robot-council-stop-hookSince v0.4.28 a Composer install has it too, at resources/stop-hook/robot-council-stop-hook inside the installed package.
Put it where the harness can run it.
robot-council has to be on the PATH the harness hands the hook, or the script needs an absolute
path in place of it -- and that path needs quoting if it contains a space, for the reason the Claude
Code MCP section records.
The two values live in the script rather than in the harness configuration, and that is not
arbitrary. exit 2 makes Claude Code print the entire hook command string into the transcript
(measured on the Claude Code page), so anything put there is one misconfigured hook away from being transcript
content. The script is also the only one of the two places that all three harnesses can reach:
Cursor's stop entry has no env key at all, and the MCP block's env configures the bridge's
process rather than the hook's.
The sink is keyed by the service, the harness, and the project, and, when no project is named, by
the checkout (#299). A hook that resolves any
of them differently from the bridge beside it reads an empty sink and reports a quiet fleet.
Uncomment ROBOT_COUNCIL_PROJECT wherever the bridge was given --project.
The checkout is the git directory of the folder the session was launched in, or that folder
itself outside a repository. It is what keeps sessions in different checkouts apart: before it,
every session launched from one user-level configuration shared one sink, and the first stop hook to
run took every session's events. Each worktree has its own git directory, so each slot gets its own
sink. Two sessions in one checkout still share a sink, so run one seat per checkout
(#300 decided that, and keeps the research
into separating them open). A second bridge started in a checkout that already has one says
so (#320): it keeps running, since an operator
may be mid-handover, but writes robot-council: another bridge is running in this checkout (pid N)
to stderr and repeats the warning in what the agent reads at connect and in the join result, so
the agent can tell its operator. A bridge given --project checks that project instead, so two
checkouts sharing one project warn too. The check is an operating-system lock on the sink, released
when the bridge exits however it exits, so a killed bridge leaves nothing behind to warn about.
The bridge and the hook have to agree on that folder, and they do not start in the same one.
Measured on Claude Code 2.1.282: the MCP server runs in the folder Claude Code was launched from,
but a Stop hook runs in the session's current folder, which an agent's cd moves. After a cd
into an --add-dir repository, the hook ran in that repository. What stays put is
CLAUDE_PROJECT_DIR: set for the hook, it still named the launch folder. So robot-council pending
resolves the checkout from CLAUDE_PROJECT_DIR when it is set, and from its own folder otherwise.
The MCP server receives it too (an earlier note here said it did not; that probe was wrong, because
Claude Code expands ${VAR} in .mcp.json itself), and needs none, since it stays where it started.
Cursor's sink has no checkout in its key (#327).
Measured on Cursor 3.17.19: Cursor runs one bridge for the whole application, in the home folder,
and runs its stop hook in ~/.cursor, so neither side can see the project, and from v0.4.23 to the
fix the two keys never matched and a Cursor seat received nothing. One bridge per application means
one sink per application is the right match. Codex is unmeasured, and keeps the checkout key.
Upgrading past this drops events already waiting under the old shared key. A seat without
--project starts reading its checkout's sink, and nothing reads the old shared one again. Those
events are not delivered again. They are the ones no session had read, often addressed to a session
that has since ended.
The script fails open, on purpose and invisibly. || exit 0 means a missing robot-council, an
unset ROBOT_COUNCIL_SERVICE, or a harness it cannot name all end the turn normally rather than
blocking it -- the right direction, because a broken hook must not trap an agent in a turn it cannot
finish. The cost is that every one of those looks exactly like a fleet with nothing to say. That is
what the verification is for, and it is worth running once per machine rather than trusting the
silence.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/absolute/path/to/robot-council-stop-hook",
"timeout": 20
}
]
}
]
}
}Set ROBOT_COUNCIL_HARNESS=codex in the script.
Not run, and the block above is this project's arrangement rather than a quoted example. Codex is
not installed on the machine this was written on. What was checked, on 2026-09-22, is that OpenAI's
hooks reference gives {"decision": "block", "reason": "..."} as the continuation output and
stop_hook_active as the loop guard -- the same two the Claude Code path uses, which is why the
script needs no Codex-specific branch. The nesting, type, and timeout around them are copied from
the Claude Code shape because the reference presents the same structure; the file it belongs in, and
the equivalent [[hooks.Stop]] block for config.toml, were not confirmed. Send a correction if it
does not fire.
For claude typed in a Solo terminal, the Claude Code page is the whole of it (#319): the bridge found the stop hook in ~/.claude/settings.json, as its log recorded, and the run is written up under Solo in Wiring the bridge into a harness. A turn-boundary delivery through that hook was not observed there. Solo's own agent launcher was not run, and no Solo documentation was read. If it launches one of the other harnesses, that harness's section is the whole of it -- but that is the expectation recorded in #6, not a measurement.