Repository navigation
[AI-730] CLI: Cursor hooks dispatcher and setup wiring - #105
alexeyzimarev merged 26 commits into
Conversation
17-task TDD plan covering CursorHookCommand dispatcher with 2s wall-clock budget, per-session canonical-event spool, watermark-driven transcript backfill, CursorHooksInstaller marker, plugin install --cursor, npm postinstall refresh, and setup wiring via CodingAgentsStep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of CodexHooksParser for Cursor's flat hooks.json schema. Recognises "kapacitor hook --cursor" entries; HasKapacitorHooksFor gates the marker installer's pre-marker detection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detection by user-dir presence (~/.cursor/ universal + per-OS Electron User dirs), not by PATH — Cursor IDE users without the cursor shell command must still be detected. UserHooksJson and SpoolDir centralise the on-disk paths the hook dispatcher and installer use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of CodexHooksInstaller from AI-734. Tracks the .kapacitor-hooks-version marker next to ~/.cursor/hooks.json so the npm postinstall hook can refresh Cursor hook command strings on CLI upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No-retry single-attempt overloads with a hard per-call timeout. The retry wrapper's 30s default is unsafe under the 2s hook-dispatcher budget; under server idempotency the next hook invocation is a free retry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralises camelCase->kebab-case event-name mapping and the canonical-event vs telemetry classification used by both the dispatcher and the spool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-session JSONL spool for failed canonical-event hooks (sessionStart, sessionEnd, beforeSubmitPrompt, afterAgentThought). FIFO drain at next invocation; 1 MB cap with oldest-first eviction; 30-day reaper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Watermark-GET-then-line-POST loop bounded by the dispatcher budget. Uses GetOnceAsync / PostOnceAsync with 500ms / 1s per-call timeouts. No retries — next hook invocation resumes from the advanced server watermark. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads stdin JSON, normalises session_id, injects home_dir / agent_host_id, honours DisabledSessions, drains the per-session spool, POSTs the current event, and runs a watermark-driven transcript backfill — all bounded by a 2s wall-clock budget. afterAgentThought gets a stable (session, gen, text) canonical event ID. Telemetry-only hooks (afterAgentResponse, preToolUse, postToolUse, postToolUseFailure) never spool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kapacitor hook --cursor dispatches to CursorHookCommand.Handle. Without a vendor flag, prints a usage message and exits 1. --codex and --claude will join here under AI-732 / AI-733. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds kapacitor plugin install --cursor [--if-installed] and remove --cursor following the existing --codex shape. InstallCursorHooks writes the bare 'kapacitor hook --cursor' command for all 8 events into ~/.cursor/hooks.json, preserving user-authored entries and replacing existing kapacitor entries. PATH precheck on the non-postinstall path guards against writing a config that will silently fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds plugin install --cursor --if-installed to the npm postinstall refresh list. Gated by the .kapacitor-hooks-version marker — no-ops on machines where Cursor hooks were never opted into. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodingAgentsStep gains a Cursor branch parallel to Codex. SetupCommand detects Cursor via CursorPaths.IsInstalled() (user-dir presence) rather than AgentDetector.IsInstalled (PATH probe) so IDE users without the cursor shell command are still detected. --skip-cursor-hooks suppresses the prompt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README and help-* now describe kapacitor plugin install --cursor and the --skip-cursor-hooks setup flag. Detection-by-user-dir behaviour is called out so IDE users without the cursor shell command know they're covered. Split help-hook.txt into help-hook-event.txt (existing per-event Claude hook help template) and a fresh help-hook.txt for the new vendor-agnostic 'kapacitor hook --vendor' command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The home_dir_and_agent_host_id_are_injected test mutates KAPACITOR_AGENT_ID; without the parallel guard another concurrent test could observe the injected value or the cleared state mid-flight. Matches the pattern used in CodexHookCommandTests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review Summary by Qodo[AI-730] CLI: Cursor hooks dispatcher and setup wiring
WalkthroughsDescription• Add Cursor IDE hook dispatcher with 2-second budget and per-session spool • Implement transcript backfill with watermark-driven line-by-line POST • Add no-retry HTTP helpers PostOnceAsync/GetOnceAsync for hook paths • Integrate Cursor detection and hooks installation into setup wizard • Support kapacitor plugin install/remove --cursor with marker tracking Diagramflowchart LR
A["Cursor IDE<br/>hook event"] -->|stdin JSON| B["CursorHookCommand<br/>dispatcher"]
B -->|normalize &<br/>inject metadata| C["CursorHookEventMap<br/>route resolver"]
C -->|canonical event| D["CursorHookSpool<br/>per-session JSONL"]
C -->|POST to server| E["Server hooks<br/>endpoints"]
D -->|FIFO drain| E
B -->|watermark GET| F["CursorTranscriptBackfill<br/>line backfill"]
F -->|POST lines| E
G["SetupCommand"] -->|detect| H["CursorPaths<br/>IsInstalled"]
G -->|install| I["PluginCommand<br/>InstallCursorHooks"]
I -->|write| J["~/.cursor/hooks.json<br/>+ marker"]
K["npm postinstall"] -->|refresh| I
File Changes1. src/Kapacitor.Cli/Commands/CursorHookCommand.cs
|
Code Review by Qodo
1.
|
Wrap HandleCore body in try/catch(OperationCanceledException) so a budget timeout or external cancellation never bubbles out as an unhandled exception. Add a unit test exercising the zero-budget path. Expose fx.Client on the test Fixture to support the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a SafeSessionId regex (^[0-9a-fA-F]{32}$) and make PathFor return
null for non-matching IDs. Guard Append, DrainAsync, and DeleteSession
against null paths so an attacker-controlled session_id cannot traverse
outside the spool directory. Update existing spool tests to use a
proper 32-hex session ID constant. Add an unsafe_session_id_is_rejected_silently test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add [NotInParallel("HomeEnvVarMutation")] at the class level to share
the same serialization lock as PluginCommandClaudeTests and other
HOME-mutating test classes, reducing parallel pressure on the flaky
Remove_claude_deletes_marker victim test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The class-level [NotInParallel("HomeEnvVarMutation")] on
PluginCommandClaudeTests serialised the class against OTHER HOME-mutating
classes but did NOT prevent its own methods from running concurrently —
five of its tests mutate HOME and raced against each other in the full
suite. The bug was latent on main and surfaced deterministically once
AI-730's added tests shifted scheduling. Promote the lock to method level
on every HOME-mutating test in PluginCommandClaudeTests (and combine with
the existing ConsoleStreams key on the one method that needs both), and
add the class-level guard to CursorHookCommandTests too since several of
its tests touch HOME-derived paths via DisabledSessions and
PathHelpers.HomeDirectory.
Verified clean across 5 consecutive full-suite runs (1111/1111 each).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…esh event Three review-driven fail-open hardenings to CursorHookCommand: 1. The 2 s wall-clock budget now wraps CreateAuthenticatedClientAsync too. That call hits /auth/config on a plain HttpClient with no per-call timeout; an unreachable-but-hanging server previously blocked Cursor before HandleCore's stopwatch even started. Handle now starts the Stopwatch + CTS up front, threads the ct into the auth call, and forwards the remaining budget to HandleCore. 2. When spool drain consumes the budget, the fresh canonical event is now appended to the spool before returning instead of being silently dropped. A large or slow replay backlog would otherwise lose new sessionStart / sessionEnd / beforeSubmitPrompt / afterAgentThought payloads permanently. 3. The catch in HandleCore is broadened from OperationCanceledException to all exceptions. The dispatcher's contract is "never crash Cursor"; any IOException from a transcript-file race, JSON quirk we missed, or unforeseen runtime fault should fail-open with exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hook command was intentionally kept out of hookCommands (that list drives the templated help for Claude per-event commands), but the KAPACITOR_SKIP=1 short-circuit only checked hookCommands. Nested headless invocations were not skipping kapacitor hook --cursor and could loop Cursor hook payloads back into kapacitor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manual `kapacitor plugin install --cursor` already guards against
writing hooks.json when kapacitor isn't on PATH, but `kapacitor setup`
called the installer directly and skipped that check. CodingAgentsStep
now takes a KapacitorOnPath delegate and HandleCursorHooks calls it
before InstallCursorHooks, surfacing a setup warning instead of writing
a config that Cursor cannot execute. SetupCommand wires it to
AgentDetector.IsInstalled("kapacitor").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The design pins ~/.cursor/hooks.json as the single Cursor hook config
("No project-scope writes from setup."), and PluginCommand.InstallCursor
ignores --project. README and help-plugin.txt were claiming --project
worked for --cursor, which would silently mislead users. Clarified both
surfaces: --project has no effect with --cursor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2 s CT plumbed through CreateAuthenticatedClientAsync only protects /auth/config discovery. Inside TokenStore.GetValidTokensAsync the expired- token refresh path (RefreshGitHubAsync at TokenStore.cs:161 and RefreshAuth0Async at line 189) calls HttpClient.PostAsync with NO cancellation token and the default 100 s timeout. An expired Bearer with an unresponsive /auth/refresh or Auth0 token endpoint could therefore block kapacitor hook --cursor far past the 2 s budget before HandleCore even starts. Wrap the whole pipeline in a Task.WhenAny race against Task.Delay(budget). If the budget fires the inner task is abandoned — the process exits 0, GC reclaims the inner task's resources, and Cursor's agent loop is unblocked. Inner task ignoring the CT is now the no-op failure mode rather than a Cursor-blocking one. Tests: - hard_cap_returns_zero_when_inner_ignores_cancellation - hard_cap_returns_inner_result_when_inner_finishes_first A proper fix would thread the CT through TokenStore.GetValidTokensAsync, RefreshGitHubAsync, and RefreshAuth0Async, but that's an API change used by many callers; a follow-up can do it without changing the hook contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The plan rev had placeholder route names (/api/cursor-sessions/.../transcript-watermark,
/hooks/transcript-line/cursor). The AI-731 server merge reused the existing
shared transcript pipeline instead:
- Watermark GET → GET /api/sessions/{sid}/last-line (the shared watermark
every transcript-driven normalizer uses). Response: 200 with
{"last_line_number": N}, 204 NoContent for empty streams, 404 when the
stream doesn't exist. Backfill now treats 204 as resumeFrom=0.
- Transcript POST → POST /hooks/transcript with a TranscriptBatch
({session_id, lines[], line_numbers[], vendor: "cursor"}). The server's
INormalizerSelector routes vendor=cursor to CursorTranscriptNormalizer.
The dispatcher now sends one batched POST per invocation (1.5 s budget)
rather than one POST per line. Practical Cursor transcripts are dozens of
lines, not thousands; the server handles them in a single
ProcessTranscriptBatchAsync.
Tests updated for the new shape; new coverage for 204 NoContent watermark
and budget-already-expired-before-batch-post.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server's HandleSessionEnd clears the CursorAttachmentsFifo for the session as soon as it accepts /hooks/session-end/cursor. With the old post-first ordering, the final transcript drain could include a role:"user" line whose beforeSubmitPrompt attachments were still queued in that FIFO — but by the time the transcript normalizer ran, the FIFO was already empty and the turn lost its attachments. Special-case sessionEnd: run CursorTranscriptBackfill BEFORE the hook POST so the FIFO survives long enough for the final user line to attach. Other events keep the existing post-then-backfill ordering — lifecycle metadata still reaches the server before any new transcript context, and beforeSubmitPrompt etc. don't trip the same race because their attachments are added on the way in, not removed on the way out. If the pre-post drain consumes the budget we still spool the unsent sessionEnd payload so a later invocation can deliver it. Tests: - sessionEnd_drains_transcript_before_posting_terminal_hook - non_sessionEnd_events_still_post_before_backfill (regression guard) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Adds the CLI side of AI-669 — live ingest of Cursor IDE Agent sessions via Cursor's hooks API. Must ship together with AI-731 (server routes + transcript-line dedup); neither half is independently useful.
Linear: AI-730
Design: `docs/superpowers/specs/2026-06-01-ai-669-cursor-hooks-ingest-design.md` (rev 7)
Plan: `docs/superpowers/plans/2026-06-02-ai-730-cursor-hooks-dispatcher.md`
What changed
What did NOT change
Reconciliation points with AI-731
These were owned by AI-731 in the design; verify the server uses the matching strings before merge:
Test Plan
Follow-ups gated on this PR landing
🤖 Generated with Claude Code