Skip to content

fix(framework): Cursor's turn-end hook never fires headless #680

Description

@blafourcade

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 declared stop, beforeSubmitPrompt, preToolUse, postToolUse, beforeReadFile, sessionStart and sessionEnd in its .cursor/hooks.json:

sessionStart
preToolUse   tool_name=Read   tool_input.file_path=.../.cursor/skills/probe-echo/SKILL.md
beforeReadFile
postToolUse  tool_name=Read   duration=352.413
preToolUse   tool_name=Read
preToolUse   tool_name=Write
postToolUse  tool_name=Write  duration=35.517
sessionEnd   reason=completed  duration_ms=19802  final_status=completed

stop and beforeSubmitPrompt never fired. sessionEnd did. Five other events from the same file fired, so this is neither a load failure nor a scope problem.

Why it matters

CURSOR_EVENT_MAP in cli/src/domain/formats/flat-hooks-merge.ts:32-38 maps Stop -> stop and has no entry for sessionEnd. The run journal's turn-end line is driven by Stop (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 stop is not established; the same probe run without -p settles it. Do not change the mapping before that is known - sessionEnd and stop are not interchangeable if both can fire.

Done when

  • It is established, by probe, whether stop fires in an interactive Cursor session.
  • A turn boundary is recorded on Cursor in both modes, or the mode where it cannot be is named in the docs rather than left silent.
  • A regression test fails if the event Cursor actually delivers stops being mapped.

Also found

A single unknown event name in .cursor/hooks.json invalidates the entire file, silently: the validator rejects the whole config and the loader stores nothing for that source. CURSOR_EVENT_MAP emits only names from Cursor's 21-name enum today, but nothing in the build asserts that.

Relations

Field Value
related #617, #663

Activity

  1. moved this from Ideation to Todo in AIDD Roadmapon Aug 20, 2026
  2. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    A second Cursor gap, one level up from this one

    While verifying #698 I ran two headless cursor-agent -p probes against a plugin-scope hook — ~/.cursor/plugins/local/<plugin>/hooks.json — declaring beforeSubmitPrompt, preToolUse, stop and a Claude-shaped SessionStart, 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 before stop versus sessionEnd matters, there is a prior question: whether a plugin's own hooks.json is loaded at all, and what registers it — nothing in ~/.cursor/cli-config.json, argv.json or ide_state.json names 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 sessionEnd correctly changes nothing on that route.

  3. blafourcade commented on Aug 22, 2026

    @blafourcade
    ContributorAuthor

    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 them nothing, 0 of 7 events, across auto-discovery and explicit --plugin-dir with a manifest matching Cursor's own validated schema, headless and interactive
    .cursor/hooks.json in the project — where aidd framework build --target cursor --flat puts them stop fired twice in one interactive session, and the journal wrote a real run file

    The run file carries Cursor's own conversation id, a session_start, and two turn_end lines from two genuine stop firings (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 --help exposes only marketplace subcommands, and the CLI binary's own strings contain no reference to scanning plugins/local at all.

    Two things found before spending a paid session

    Both by dry-running journal.js against real-shaped payloads, at no cost:

    • Cursor resolves the repository root from payload.workspace_roots, not cwd. Every other host uses cwd, and the hook reads cwd. Without this the journal resolves by accident or not at all — it is the reason the first attempts wrote nothing.
    • Commands in .cursor/hooks.json resolve relative to the project root, the directory holding .cursor/. Confirmed against flat-build-strategy.ts's own path construction and then empirically.

    What this ticket asked, answered

    It is established, by probe, whether stop fires 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 sessionEnd and not stop, 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_MAP still has no SessionEnd key, so a headless install would journal a start and never a boundary.

    CURSOR_EVENT_MAP was 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 journalAttributable to false in the plugin's declaration, and a new conformance test caught that the CLI computed true for 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.

  4. moved this from Todo to In review in AIDD Roadmapon Aug 22, 2026
  5. blafourcade commented on Aug 22, 2026

    @blafourcade
    ContributorAuthor

    Done and awaiting review in #706, which closes this on merge.

    The work is on claude/aidd-telemetry-layer-e403uf, eleven commits, targeting next. Gate at the time of push: 365 plugin specs, 1,931 CLI unit, 577 integration, 178 e2e, tsc and 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 next has 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.

  6. blafourcade commented on Sep 2, 2026

    @blafourcade
    ContributorAuthor

    Delivered by #706, squash-merged into next as 627408f.

  7. moved this from In review to Done in AIDD Roadmapon Sep 2, 2026
  8. added theissue type on Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    High

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions