-
Notifications
You must be signed in to change notification settings - Fork 0
The stop hook under Cursor
Describes robot-council/cli v0.4.28.
In ~/.cursor/hooks.json (global) or .cursor/hooks.json (project):
{
"version": 1,
"hooks": {
"stop": [
{
"command": "/absolute/path/to/robot-council-stop-hook",
"loop_limit": 10
}
]
}
}Set ROBOT_COUNCIL_HARNESS=cursor in the script. Cursor returns followup_message, which its
documentation describes as submitted automatically as the next user message, and it caps
continuations with loop_limit rather than with a field on the payload -- which is why the script's
own stop_hook_active guard never fires here and loop_limit is the only thing bounding it.
Verified 2026-09-22, on Cursor 3.7.21 and Windows 11 Pro 26200, against a live fleet, by driving
the editor's own agent rather than a headless runner -- there is none on Windows, for the reason
below. Three turns of say hello and stop, differing only in whether the sink held anything and
whether the hook was configured:
| sink | hook | hook executions | the directive in the conversation | sink |
|---|---|---|---|---|
| one directive | configured | 2 | present, verbatim | 182 B to 0 B |
| empty | configured | 1 | absent | empty throughout |
| one directive | removed | 0 | absent | 182 B to 182 B |
The third row is the control, and it is what makes the first mean anything: same planted sink,
hooks.json moved aside, nothing injected, and the sink was 182 bytes on both sides.
The first two rows come from one turn rather than two. The hook fired, drained the sink and
continued the turn; the agent worked on what arrived; and when it stopped again the hook fired a
second time against the now-empty sink, printed nothing, and let the turn end. Cursor's stop payload
carries loop_count, and the two firings arrived as 0 and 1.
Cursor adds no wrapper. What arrives is a user message carrying the script's bytes and nothing else, quoted verbatim from the first run:
The fleet has news:
[directive] cli#72 probe: confirm Cursor submits followup_message as the next user message. from an unnamed session
The fleet has news: is the script's own line. Claude Code prefixes its equivalent with
Stop hook feedback:; Cursor prefixes nothing, so whatever the hook prints is the whole message.
The stop payload, as it actually arrives -- previously taken from Cursor's documentation:
{"conversation_id":"…","generation_id":"…","model":"default","status":"completed",
"loop_count":0,"input_tokens":21434,"output_tokens":27,"cache_read_tokens":5504,
"cache_write_tokens":0,"session_id":"…","hook_event_name":"stop","cursor_version":"3.7.21",
"workspace_roots":["/d:/GitHub Repos/robot-council/cli"],"user_email":"…","transcript_path":"…"}stop_hook_active is absent, which is why the script's own loop guard never fires here and
loop_limit is the only thing bounding it -- now measured rather than read from the documentation.
The payload arrives with a UTF-8 BOM. Measured: the first three bytes on stdin are ef bb bf,
before the {. What that costs depends on the parser, and the obvious guess is wrong. Measured on
macOS 26.6.2, each reading controlled against the same document without a BOM:
| parser | a BOMed payload |
|---|---|
jq 1.7.1 |
parsed, correct value. src/jv_parse.c has skipped a leading BOM since at least the jq-1.6 tag |
PHP 8.4.23 json_decode
|
refused -- Syntax error, which does not mention a BOM |
Python 3.9.6 json.loads
|
refused -- Unexpected UTF-8 BOM (decode using utf-8-sig)
|
All three still refuse genuinely malformed input, so the table records what the parsers do rather than an instrument that cannot tell the two apart.
php is the row that matters here, because this script already invokes it to encode the reply,
so it is the nearest tool to hand for anyone who replaces the substring matching with parsing -- and
its message names a syntax error rather than the BOM. By this script's own design a payload it
cannot read ends the turn with the sink untouched, so that failure reads exactly like a quiet fleet.
The script strips the BOM at the top for that reason; the matching would survive without it.
Claude Code's payload carries no BOM, measured on 2.1.236: the first bytes on stdin are
{"session_id". Codex's is unmeasured, because it is installed on no machine this project is
worked from -- which is not the same as having looked.
On Windows the command is a path to a .cmd, not to the bash script. Cursor's execution log
names the mechanism windows_temp_file: it writes the command to a temporary script and runs that.
A bash script is not directly executable on Windows whatever the shell answer turns out to be, so
the entry points at a two-line .cmd that invokes Git Bash on the hook:
{
"version": 1,
"hooks": {
"stop": [
{
"command": "C:\\Users\\<you>\\bin\\robot-council-stop-hook.cmd",
"loop_limit": 10
}
]
}
}@echo off
"C:\Program Files\Git\bin\bash.exe" "%USERPROFILE%\bin\robot-council-stop-hook"stdin passes through the shim untouched. Whether Cursor on Windows would have interpreted a shell line directly is still unmeasured, and the shim is deliberately the arrangement that does not depend on the answer. The macOS answer, below, is not evidence for Windows: the mechanism there is a temporary script, and what interprets that script was not observed.
The two environment variables reach the hook by being written into the script, which is what the
script section recommends and what was run on Windows. A stop entry has no env key. Prefixing
them onto command works on macOS (measured below), but the recommendation stays with the script:
it is the one arrangement that works on every harness and platform these pages cover, and the
Windows answer is not known.
Verified 2026-09-24: on macOS a stop hook's command is shell-interpreted. Cursor 3.17.19 on
macOS 26.6.2, driving the editor's own agent with one turn of Say the single word READY and stop.
in a throwaway workspace whose .cursor/hooks.json held four stop entries (<dir> stands for an
absolute path with no spaces):
{
"version": 1,
"hooks": {
"stop": [
{ "command": "<dir>/probe-control.sh" },
{ "command": "printf '%s' ~ > <dir>/out/tilde.out" },
{ "command": "FOO=bar <dir>/probe-env.sh" },
{ "command": "<dir>/probe-argv.sh ~ '$HOME' \"two words\"" }
]
}
}probe-env.sh records FOO=${FOO-} set=${FOO+yes}, which tells an unset variable from an empty one;
probe-argv.sh records $# and each argument in brackets. Before Cursor ran them, the argument
probe was run both through sh -c and as a whitespace-split exec, so that the two possible answers
were known to look different:
| probe | through sh -c
|
whitespace-split, no shell | Cursor's stop
|
|---|---|---|---|
argv.out |
argc=3 [/Users/<you>] [$HOME] [two words]
|
argc=4 [~] [$HOME] ["two] [words"]
|
argc=3 [/Users/<you>] [$HOME] [two words]
|
tilde.out |
not run | not run | /Users/<you> |
env.out |
FOO=bar set=yes |
not run | FOO=bar set=yes |
tilde.out existing at all means > was honored as a redirect: without a shell, > and the path
would have reached printf as arguments and no file would have been written. cursor.hooks.*.log
recorded Found 4 hook(s) to execute for step: stop and four exit code: 0, and the control wrote
its timestamp once.
A second editor turn, in a new chat, named the interpreter and took the baseline for FOO. Its
stop entries were the control, probe-env.sh with no prefix, and
{ "command": "echo \"zero=$0 comm=$(ps -o comm= -p $$) sub=$(echo ok)\" > <dir>/out/shell.out; echo seq=ok >> <dir>/out/shell.out" }which wrote
zero=/bin/bash comm=/bin/bash sub=ok
seq=ok
against zero=sh comm=sh sub=ok and seq=ok from the same line under sh -c. The unprefixed
probe-env.sh wrote FOO= set=, so the bar in the first turn came from the prefix and not from
Cursor's own environment. So on macOS Cursor hands a stop command to /bin/bash: ~
expands, quotes are removed, single quotes suppress $, command substitution and ; work, and a
VAR=value prefix reaches the script. The log names no mechanism on macOS, where Windows names
windows_temp_file.
hooks.json is picked up without restarting Cursor. Measured: the file was written at
17:45:36 UTC and cursor.hooks.*.log recorded Reloading hooks configuration... at 17:45:37.777Z
and Loaded 1 user hook(s) for steps: stop at 17:45:38.019Z. Settings, then Hooks, shows the loaded
entry and an Execution Log -- which is the instrument to reach for, because it distinguishes a
hook that fired and said nothing from one that never fired at all.
But it watches for writes, not for removal, and that one bit me. Moving hooks.json aside to
disable the hook did nothing: Settings went on showing Configured Hooks (1), and the hook went on
firing and draining the sink. Writing a config with an empty hooks object instead produced
Reloading hooks configuration... and Loaded 0 user hook(s) for steps: within a second, and the
entry disappeared from Settings. Disable a Cursor hook by writing an empty configuration, not by
deleting the file -- otherwise it keeps running while every visible sign says it is gone, which for
this hook means it keeps emptying the sink.
Two things in those logs are noise rather than a fault. ERROR: Failed to parse project hooks configuration is followed immediately by No project hooks configuration found, and this
repository ships no .cursor/hooks.json -- Cursor logs an error for an absent project config. And
the log files report 0 bytes while holding content, because the size is not flushed to the
directory entry while Cursor holds the handle; read them rather than measuring them.
robot-council was not on PATH on the machine this was run on, and that is worth stating
because of how it failed. With a bare robot-council the hook's || exit 0 turned
command not found into a turn that ended quietly -- byte-identical to a quiet fleet, with nothing
anywhere reporting it. The script here names the executable absolutely. Check the binary resolves
before trusting a quiet run.
Cursor 3.17.19 is installed on the machine this was written on, and cursor agent is a headless
runner shaped like claude -p: -p/--print with --output-format text | json | stream-json,
which is what the Claude Code measurements were taken with. The shape is where the
resemblance ends for this hook, because -p does not run stop at all (below).
agent is routed by the cursor launcher script, not by the Cursor binary, and invoking the
binary directly fails silently. On macOS /usr/local/bin/cursor is a 142-line shell script whose
routing is three branches: editor and anything unrecognized go to the editor CLI, and agent
execs ~/.local/bin/cursor-agent, installing it from cursor.com/install first if it is missing
and enforcing a minimum version. The editor CLI treats arguments it does not recognize as paths to
open, so bypassing the script turns cursor agent login into two empty editor tabs named agent
and login, with no error.
cursor --help does not discriminate, which is what makes this worth writing down. Cursor 3.7.21
on Windows lists agent under Subcommands in the help printed by cursor.exe, and that same
invocation does not route it -- the binary advertises a subcommand the launcher implements. Both
3.7.21 and 3.17.19 list it; only one of the two invocations acted on it.
So call cursor-agent directly rather than through cursor agent, and install it from
cursor.com/install where it is missing. That is one binary with one behavior, instead of a
launcher whose presence decides what the same command line means. Invoking it installs
cursor-agent from cursor.com/install on first use; here that produced 2026.09.18-9a7762b in
~/.local/bin, which is not on the default PATH. cursor agent status then reported Not logged in, and cursor agent login is a browser flow, so nothing was run on 2026-09-22. The runs dated
2026-09-24 below followed a browser login, made with the full path because ~/.local/bin was still
not on PATH.
This is corrected from an earlier reading of "no headless binary", which came from looking for a
cursor-agent on PATH and reading the top of cursor --help. The Subcommands block naming
agent is at the bottom of that same output. A narrower question than the one that mattered,
answered in the reassuring direction.
Under cursor-agent -p, no stop hook wrote its marker. Measured 2026-09-24 with
cursor-agent 2026.09.18-9a7762b on macOS 26.6.2, logged in, running cursor-agent -p --trust 'Say the single word READY and stop.' in a throwaway workspace. Three runs, each exiting 0 and printing
READY:
| run | project .cursor/hooks.json
|
markers written |
|---|---|---|
| 1 | the four stop probes; the workspace not a git repository |
none |
| 2 | the same, after git init
|
none |
| 3 | one marker command under each of sessionStart, beforeSubmitPrompt, afterAgentResponse, stop
|
sessionStart only |
Run 3's sessionStart line is the control, and only run 3 has it: it shows the project config was
read and a hook does execute under -p, so the silence from stop is not a config that never
loaded. beforeSubmitPrompt and afterAgentResponse were silent too, so -p skips more of the
agent loop than stop alone. What the markers cannot tell apart is a hook never started from one
started and killed as the process exited; no cursor-agent hook log was found to settle it. Either
way -p did not exercise this hook, and the measurement above drove the editor. Interactive
cursor-agent, without -p, was not tried.
The three recording probes above, moved under sessionStart, produced files byte-for-byte identical
to the editor's stop results in a fourth headless run -- ~ expanded, FOO=bar reached the
script, argc=3. That is the CLI's hook runner on another step, recorded as corroboration rather
than as the stop measurement.
On Windows none of this can be measured headless: cursor-agent is not installable there.
cursor.com/install is a bash script whose uname -s case accepts Linux* and Darwin* and exits
1 on anything else, and it mentions
Windows nowhere. The runs recorded below drove the editor's agent instead, which is a different
question with a different answer and is labeled as such.
And the Windows launcher does not route agent at all, which the paragraph above predicts and
which is worth having measured directly rather than inferred twice over. cursor.cmd on Windows is
seven lines that hand every argument to the editor CLI, with none of the macOS launcher's branches:
"%~dp0..\..\..\Cursor.exe" "%~dp0..\out\cli.js" %*So cursor agent --help on 3.7.21 prints the editor's help, exits 0, and reports no error --
while listing, at the bottom of that same output, agent Start the Cursor agent in your terminal.
The binary advertises a subcommand it does not implement. Reading that help is how this was got
wrong in both directions before it was run.
What was run, on 2026-09-22: given a planted sink and Cursor's documented stdin, the script printed
{"followup_message":"The fleet has news:\n[directive] PROBE-INDIA cursor shape from otherdev at 2026-09-22T16:40:00+00:00"}
and cleared the sink. So the claim is that the script emits the documented shape and selects it from the payload correctly, not that Cursor accepts it.
beforeMCPExecution and afterMCPExecution are outbound only -- they gate and audit rather than
feed the agent -- so wiring this to the bridge's own MCP traffic is not available. stop is the hook
that matches a turn boundary, but it is not Cursor's only injecting hook: postToolUse and
postToolUseFailure carry additional_context, and sessionStart carries it too. Read from Cursor's
hooks documentation on 2026-09-22 and none of it run. postToolUse is the interesting one and is
deliberately not documented here -- it would deliver fleet events after every tool call rather than
once a turn, which is a different trade in interruption and cost than the one
#59 settled.