Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

6.6 KiB

Cursor Hook Stdin Shape — Reference

This file documents the JSON payloads the Cursor IDE sends to the MemPalace hook scripts in hooks/cursor/. It exists so a future contributor does not have to re-discover the schema by writing a probe hook.

Source: cursor.com/docs/hooks.md, fetched 2026-05-27. Cursor's hook system is documented as a stable v1 schema ({"version": 1, ...} at the top of hooks.json).

If you suspect Cursor has changed the payload shape since that fetch date, re-verify against the upstream docs and update both this file and hooks/cursor/lib/common.sh::mempal_parse_stdin. The hook scripts deliberately ignore fields they do not consume, so adding new fields is non-breaking.

Common fields (all events)

Every hook receives these on stdin in addition to its event-specific fields. Source: docs section "Common schema → Input (all hooks)".

{
  "conversation_id": "string",
  "generation_id":   "string",
  "model":           "string",
  "hook_event_name": "string",
  "cursor_version":  "string",
  "workspace_roots": ["<absolute path>"],
  "user_email":      "string | null",
  "transcript_path": "string | null"
}

Field notes (verified):

  • conversation_id is the stable per-conversation ID. The Cursor stop event does not carry a session_id — only conversation_id. MemPalace keys its counter files on this. Cursor sessionStart does carry a session_id, and the docs note it is "same as conversation_id".
  • generation_id changes every user message. We do not use it.
  • transcript_path may be null if the user has disabled transcripts in Cursor settings. The hooks degrade gracefully when the value is empty.
  • workspace_roots is normally a single-entry array but multi-root workspaces are supported; MemPalace uses index [0].

Event-specific fields

stop (consumed by mempal_save_hook_cursor.sh)

{
  "status":     "completed" | "aborted" | "error",
  "loop_count": 0
}
  • loop_count indicates how many times this stop hook has already triggered an automatic followup for this conversation (starts at 0). When loop_count > 0 we know our own previous followup_message is currently being processed — the save hook returns {} so the agent can finish. Equivalent to Claude Code's stop_hook_active.
  • The per-script loop_limit (default 5 for Cursor hooks, configurable via the loop_limit field on the hook entry in hooks.json) is defense-in-depth on top of our own check. The example hooks.json in examples/cursor/ sets loop_limit: 1.

Allowed output fields (only):

{ "followup_message": "<text to auto-submit as next user turn>" }

preCompact (consumed by mempal_precompact_hook_cursor.sh)

{
  "trigger":               "auto" | "manual",
  "context_usage_percent": 85,
  "context_tokens":        120000,
  "context_window_size":   128000,
  "message_count":         45,
  "messages_to_compact":   30,
  "is_first_compaction":   true
}

Critical constraint: preCompact is documented as observational only. It cannot block compaction and its allowed output fields are limited to:

{ "user_message": "<short message shown to the user when compaction occurs>" }

There is no followup_message and no decision: block on this event — unlike Claude Code's PreCompact. MemPalace works around this by:

  1. Running mempalace mine synchronously inside the hook so the verbatim transcript lands in the palace before compaction summarises it.
  2. Dropping a cursor_<conversation_id>.pending marker that the next stop invocation reads and uses to force a save followup regardless of its counter.

sessionStart (consumed by mempal_wake_hook_cursor.sh)

{
  "session_id":          "<unique session identifier>",
  "is_background_agent": true,
  "composer_mode":       "agent" | "ask" | "edit"
}

session_id equals conversation_id on this event (docs are explicit about this).

Allowed output fields:

{
  "env":                { "<key>": "<value>" },
  "additional_context": "<text added to conversation's initial system context>"
}

additional_context is the field MemPalace uses. The schema also accepts continue and user_message but the docs explicitly note "current callers do not enforce them; session creation is not blocked even when continue is false". We do not emit either.

Environment variables (all hooks)

Cursor sets these env vars on every hook execution; the hook scripts fall back to them when JSON parsing fails for any reason.

Variable Description
CURSOR_PROJECT_DIR Workspace root (= workspace_roots[0])
CURSOR_VERSION Cursor version string
CURSOR_USER_EMAIL Authenticated user email (if logged in)
CURSOR_TRANSCRIPT_PATH Conversation transcript path (if transcripts on)
CURSOR_CODE_REMOTE "true" if running in a remote workspace
CLAUDE_PROJECT_DIR Alias for CURSOR_PROJECT_DIR (Claude compat)

Exit code semantics

Cursor interprets command-hook exit codes as follows (docs "Hook Types → Command-Based Hooks → Exit code behavior"):

  • 0 — success, use the JSON output.
  • 2 — block the action (equivalent to permission: "deny").
  • Other — hook failed; action proceeds (fail-open by default).

MemPalace hooks always exit 0 and emit either {} (no-op) or a valid JSON response. We never use exit code 2; nothing MemPalace does should ever block an agent action.

Working directory contract

  • User hooks (~/.cursor/hooks.json) run from ~/.cursor/.
  • Project hooks (.cursor/hooks.json) run from the project root.

The MemPalace hooks always resolve their sibling lib/common.sh via BASH_SOURCE[0] so the working directory does not matter for the script's own loading — only the command path in hooks.json needs to point at the absolute location of the script.

Transcript file format (out of scope)

The format of the file at transcript_path is not documented by Cursor as of the fetch date above. MemPalace deliberately does not parse it: the save hook counts stop invocations (each one corresponds to one assistant turn) and hands the transcript to mempalace mine, which has its own normaliser layer.

If you need to consume the transcript directly, probe its shape with a throw-away hook that does cat > /tmp/cursor-transcript-sample.txt and inspect the output — there is no shortcut.