Repository navigation
fix(framework): Cursor's turn-end hook never fires headless #680
Description
Activity
A second Cursor gap, one level up from this one
While verifying #698 I ran two headless
cursor-agent -pprobes against a plugin-scope hook —~/.cursor/plugins/local/<plugin>/hooks.json— declaringbeforeSubmitPrompt,preToolUse,stopand a Claude-shapedSessionStart, with a script that appends to three separate paths and exits 0. Nothing fired. No file, on any of the four events, on either run.That is a different failure from this ticket. The probe recorded here fired five of seven events from
.cursor/hooks.json, project scope. Plugin scope fired zero. So beforestopversussessionEndmatters, there is a prior question: whether a plugin's ownhooks.jsonis loaded at all, and what registers it — nothing in~/.cursor/cli-config.json,argv.jsonoride_state.jsonnames the plugins sitting in~/.cursor/plugins/local/.Bounds: two invocations, headless, one machine, one plugin. Not established whether an interactive session loads them, which is the same probe this ticket already needs without
-p. Both questions are answered by one interactive run, so they are worth doing together.Worth knowing because the journal's Cursor path assumes plugin-scope hooks run. If they do not, mapping
sessionEndcorrectly changes nothing on that route.Cursor journals. The obstacle was never Cursor.
Three probes settle it, one of them interactive through a real pty:
Where the hooks live What fired ~/.cursor/plugins/local/<plugin>/hooks.json— where the framework installs themnothing, 0 of 7 events, across auto-discovery and explicit --plugin-dirwith a manifest matching Cursor's own validated schema, headless and interactive.cursor/hooks.jsonin the project — whereaidd framework build --target cursor --flatputs themstopfired twice in one interactive session, and the journal wrote a real run fileThe run file carries Cursor's own conversation id, a
session_start, and twoturn_endlines from two genuinestopfirings (status: "error"then"aborted"in the captured payloads — two events, not one delivered twice).So Cursor does not refuse our hooks. We install them into a directory nothing reads.
cursor-agent plugin --helpexposes only marketplace subcommands, and the CLI binary's own strings contain no reference to scanningplugins/localat all.Two things found before spending a paid session
Both by dry-running
journal.jsagainst real-shaped payloads, at no cost:- Cursor resolves the repository root from
payload.workspace_roots, notcwd. Every other host usescwd, and the hook readscwd. Without this the journal resolves by accident or not at all — it is the reason the first attempts wrote nothing. - Commands in
.cursor/hooks.jsonresolve relative to the project root, the directory holding.cursor/. Confirmed againstflat-build-strategy.ts's own path construction and then empirically.
What this ticket asked, answered
It is established, by probe, whether
stopfires in an interactive Cursor session.It does. Every prior probe was headless, which is why it had never been seen. The original headless probe fired
sessionEndand notstop, and that difference is real rather than a loading failure — so whichever event closes a turn has to be chosen per mode, from what each one actually fires.CURSOR_EVENT_MAPstill has noSessionEndkey, so a headless install would journal a start and never a boundary.CURSOR_EVENT_MAPwas deliberately left untouched by the probe: mapping the right event changes nothing while nothing reads the map.What follows
Work is in flight to install Cursor's hooks where Cursor reads them, read the root the way Cursor names it, and close a turn in both modes from what each fires. Plan:
aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-6.md.One correction to the record: a probe flipped Cursor's
journalAttributabletofalsein the plugin's declaration, and a new conformance test caught that the CLI computedtruefor the same tool. The CLI was right and the flag was reverted. That test —registry-conformance.unit.test.ts, "agrees with the plugin's own cost-report declaration on journalAttributable" — now pins the two implementations together per tool, which is the second time two copies of one fact have drifted here and the first time anyone would have seen it.- Cursor resolves the repository root from
- added a commit that references this issue
on Aug 22, 2026 Done and awaiting review in #706, which closes this on merge.
The work is on
claude/aidd-telemetry-layer-e403uf, eleven commits, targetingnext. Gate at the time of push: 365 plugin specs, 1,931 CLI unit, 577 integration, 178 e2e,tscand biome clean, no broken markdown links.Read the eleven commits rather than the pull request's file count — the branch carries its own copy of the CLI migration that
nexthas since received, so the diff counts it twice. The range and the real figures are in the first comment on #706: 142 files, +10,518 −418.Every claim about a tool in this work rests on a session that was actually run. The negative results are kept in full in
aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md, including the two probes that disagreed about OpenCode and the measurement that reconciled them.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
The breakage
Under
cursor-agent -p, the framework's turn-end hook receives nothing.Measured on a real invocation (
cursor-agent -p "…" --force --trust --output-format text) against a project that declaredstop,beforeSubmitPrompt,preToolUse,postToolUse,beforeReadFile,sessionStartandsessionEndin its.cursor/hooks.json:stopandbeforeSubmitPromptnever fired.sessionEnddid. Five other events from the same file fired, so this is neither a load failure nor a scope problem.Why it matters
CURSOR_EVENT_MAPincli/src/domain/formats/flat-hooks-merge.ts:32-38mapsStop -> stopand has no entry forsessionEnd. The run journal's turn-end line is driven byStop(plugins/aidd-telemetry/hooks/hooks.json), so on Cursor headless no turn is ever recorded, and the one event that does mark the end of the work is not mapped to anything.Bounds
Observed on one invocation, headless. Whether an interactive Cursor session fires
stopis not established; the same probe run without-psettles it. Do not change the mapping before that is known -sessionEndandstopare not interchangeable if both can fire.Done when
stopfires in an interactive Cursor session.Also found
A single unknown event name in
.cursor/hooks.jsoninvalidates the entire file, silently: the validator rejects the whole config and the loader stores nothing for that source.CURSOR_EVENT_MAPemits only names from Cursor's 21-name enum today, but nothing in the build asserts that.Relations