Skip to content

[AI-730] CLI: Cursor hooks dispatcher and setup wiring - #105

Merged
alexeyzimarev merged 26 commits into
mainfrom
alexeyzimarev/ai-730-cli-cursor-hooks-dispatcher-and-setup-wiring
Jun 2, 2026
Merged

alexeyzimarev merged 26 commits into
mainfrom
alexeyzimarev/ai-730-cli-cursor-hooks-dispatcher-and-setup-wiring

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

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

  • New vendor-agnostic `kapacitor hook --cursor` command — Cursor invokes it for every hook event. Reads stdin JSON, normalises `session_id`, injects `home_dir` / `agent_host_id`, honours `DisabledSessions`, drains the per-session spool, POSTs the current event, then runs a watermark-driven transcript JSONL backfill — all bounded by a 2-second wall-clock budget.
  • New no-retry HTTP helpers `HttpClient.PostOnceAsync` / `GetOnceAsync` with hard per-call timeouts. The retry wrapper's 30s default is unsafe in any hook-path call; server idempotency makes the next hook invocation a free retry.
  • `CursorHookSpool` — per-session JSONL spool at `~/.cursor/kapacitor-pending/.jsonl` for failed canonical-event hooks (`sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `afterAgentThought`). FIFO drain on next invocation, 1 MB per-file cap with oldest-first eviction, 30-day reaper. Telemetry-only hooks (`afterAgentResponse`, `preToolUse`, `postToolUse`, `postToolUseFailure`) never spool.
  • `CursorTranscriptBackfill` — GET watermark, POST transcript JSONL lines one at a time past `last_line_number` until EOF / budget / failure. `FileShare.ReadWrite` so Cursor can write concurrently. Fail-open: any watermark or line-POST failure returns cleanly so Cursor's agent loop is never blocked.
  • `CursorHooksInstaller` (mirror of `CodexHooksInstaller` from AI-734) + `CursorHooksParser`. `.kapacitor-hooks-version` marker next to `~/.cursor/hooks.json` lets the npm postinstall refresh hook command strings on CLI upgrade.
  • `kapacitor plugin install --cursor [--if-installed]` and `kapacitor plugin remove --cursor`. PATH precheck on the non-postinstall path guards against writing a config that will silently fail.
  • `npm/postinstall.js` extension: `plugin install --cursor --if-installed` joins the existing skills / codex / claude refresh list.
  • Setup wiring (`SetupCommand` + `CodingAgentsStep`): Cursor detection by user-dir presence (`~/.cursor/` and OS-specific Electron user dirs), NOT by PATH — IDE users without the `cursor` shell command are still detected. `--skip-cursor-hooks` opts out of the Step 4 prompt.
  • `hooks.json` writes the bare `kapacitor hook --cursor` command (no absolute path) — relies on the npm symlink being on Cursor's inherited PATH.
  • README + help-* sync (per CLAUDE.md): Getting Started, CLI commands, `help-setup.txt`, `help-plugin.txt`, and a new `help-hook.txt` for the vendor-dispatch command (the old per-event template moves to `help-hook-event.txt`).

What did NOT change

  • No daemon integration (out of scope per design).
  • No changes to `CursorImportSource` (SQLite) — it stays as legacy backfill for pre-transcript-file sessions.
  • No migration of existing Codex/Claude hook commands to the new `hook` surface — those follow in AI-732 and AI-733 once the dispatcher contract proves out.

Reconciliation points with AI-731

These were owned by AI-731 in the design; verify the server uses the matching strings before merge:

  • Hook event routes: `POST /hooks/{session-start|session-end|user-prompt|agent-response|agent-thought|pre-tool-use|post-tool-use|post-tool-use-failure}/cursor`. Owned in `CursorHookEventMap.cs`.
  • Transcript-line ingest: `POST /hooks/transcript-line/cursor` with body `{"session_id": "...", "line_index": N, "line": ""}`. Owned in `CursorTranscriptBackfill.cs`.
  • Watermark API: `GET /api/cursor-sessions/{sessionId}/transcript-watermark` returning `{"last_line_number": N}` (or 404). Owned in `CursorTranscriptBackfill.cs`.
  • `canonical_event_id` shape for `afterAgentThought`: stamped as `{session_id}:reasoning:{generation_id}:{sha256(text)[..16-hex]}` in `CursorHookCommand.cs`. The algorithm matches the design; confirm the field name is what the server reads.

Test Plan

  • `dotnet build src/Kapacitor.Cli/Kapacitor.Cli.csproj` — 0 errors, 0 warnings.
  • `dotnet publish src/Kapacitor.Cli/Kapacitor.Cli.csproj -c Release` — 0 `IL3050` / `IL2026` AOT warnings.
  • Unit suite (`dotnet run --project test/Kapacitor.Cli.Tests.Unit/Kapacitor.Cli.Tests.Unit.csproj`) — 1107/1108 pass. The single failure (`PluginCommandClaudeTests.Remove_claude_deletes_marker`) is a pre-existing TUnit parallel-test flake (passes in isolation; races with concurrent HOME-mutating tests in the full suite). Not introduced by this branch — none of the new tests mutate `HOME`.
  • `kapacitor hook` (no vendor flag) → exit 1 with usage on stderr.
  • `kapacitor hook --help` renders the new vendor-dispatch help text.
  • `kapacitor session-start --help` still renders correctly via the renamed `help-hook-event.txt`.
  • `kapacitor setup --help` shows `--skip-cursor-hooks`.
  • `kapacitor plugin --help` shows `--cursor`.
  • Manual smoke against a real Cursor install + deployed AI-731 server. Gated on AI-731 landing; design Q4 (Cursor version that introduced JSONL transcripts) to be verified during smoke.
  • Integration suite (WireMock end-to-end per route, slow-server budget enforcement, spool persistence across restarts). Deferred to a follow-up commit once AI-731 routes are finalised — the existing unit tests cover the dispatcher logic with mock `HttpMessageHandler` and the integration project's current shape is HTTP-contract tests, not dispatcher-driven.

Follow-ups gated on this PR landing

  • AI-732 — Migrate Codex from `kapacitor codex-hook` to `kapacitor hook --codex` (clean break).
  • AI-733 — Migrate Claude from 7 per-event commands to `kapacitor hook --claude` (clean break).
  • Integration tests against the finalised AI-731 routes (Task 14 in the plan, deferred).
  • Eventual deprecation of `CursorImportSource` once the JSONL transcript era is universal (design Q4).

🤖 Generated with Claude Code

alexeyzimarev and others added 15 commits June 2, 2026 14:33
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>
@linear-code

linear-code Bot commented Jun 2, 2026

Copy link
Copy Markdown

AI-730

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

[AI-730] CLI: Cursor hooks dispatcher and setup wiring

✨ Enhancement

Grey Divider

Walkthroughs

Description
• 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
Diagram
flowchart 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

Loading

Grey Divider

File Changes

1. src/Kapacitor.Cli/Commands/CursorHookCommand.cs ✨ Enhancement +150/-0

Cursor hook dispatcher with budget and spool

src/Kapacitor.Cli/Commands/CursorHookCommand.cs


2. src/Kapacitor.Cli/Commands/CursorHookSpool.cs ✨ Enhancement +139/-0

Per-session JSONL spool for failed canonical events

src/Kapacitor.Cli/Commands/CursorHookSpool.cs


3. src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs ✨ Enhancement +85/-0

Watermark-driven transcript line backfill

src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs


View more (29)
4. src/Kapacitor.Cli/Commands/CursorHookEventMap.cs ✨ Enhancement +30/-0

Event name to route mapping with spool flags

src/Kapacitor.Cli/Commands/CursorHookEventMap.cs


5. src/Kapacitor.Cli/Commands/PluginCommand.cs ✨ Enhancement +152/-22

Add Cursor hooks install/remove with marker

src/Kapacitor.Cli/Commands/PluginCommand.cs


6. src/Kapacitor.Cli/Commands/SetupCommand.cs ✨ Enhancement +7/-1

Integrate Cursor detection and setup wiring

src/Kapacitor.Cli/Commands/SetupCommand.cs


7. src/Kapacitor.Cli/Commands/CodingAgentsStep.cs ✨ Enhancement +50/-7

Add Cursor hooks handling to agent setup step

src/Kapacitor.Cli/Commands/CodingAgentsStep.cs


8. src/Kapacitor.Cli/Core/HttpClientExtensions.cs ✨ Enhancement +0/-0

Add no-retry PostOnceAsync and GetOnceAsync

src/Kapacitor.Cli/Core/HttpClientExtensions.cs


9. src/Kapacitor.Cli.Core/CursorHooksInstaller.cs ✨ Enhancement +65/-0

Marker and detection helpers for hooks.json

src/Kapacitor.Cli.Core/CursorHooksInstaller.cs


10. src/Kapacitor.Cli.Core/Cursor/CursorPaths.cs ✨ Enhancement +37/-0

Add IsInstalled, UserHooksJson, SpoolDir helpers

src/Kapacitor.Cli.Core/Cursor/CursorPaths.cs


11. src/Kapacitor.Cli.Core/Cursor/CursorHooksParser.cs ✨ Enhancement +57/-0

Parser for Cursor hooks.json schema

src/Kapacitor.Cli.Core/Cursor/CursorHooksParser.cs


12. src/Kapacitor.Cli/Program.cs ✨ Enhancement +8/-1

Wire hook dispatcher and update help routing

src/Kapacitor.Cli/Program.cs


13. npm/kapacitor/bin/postinstall.js ✨ Enhancement +4/-3

Add Cursor hooks refresh to npm postinstall

npm/kapacitor/bin/postinstall.js


14. src/Kapacitor.Cli.Core/Resources/help-hook.txt 📝 Documentation +23/-3

New vendor-dispatch help for hook command

src/Kapacitor.Cli.Core/Resources/help-hook.txt


15. src/Kapacitor.Cli.Core/Resources/help-hook-event.txt 📝 Documentation +5/-0

Legacy per-event hook help template

src/Kapacitor.Cli.Core/Resources/help-hook-event.txt


16. src/Kapacitor.Cli.Core/Resources/help-plugin.txt 📝 Documentation +11/-1

Update plugin help with Cursor options

src/Kapacitor.Cli.Core/Resources/help-plugin.txt


17. src/Kapacitor.Cli.Core/Resources/help-setup.txt 📝 Documentation +8/-4

Update setup help with Cursor detection info

src/Kapacitor.Cli.Core/Resources/help-setup.txt


18. README.md 📝 Documentation +25/-6

Document Cursor hooks setup and commands

README.md


19. test/Kapacitor.Cli.Tests.Unit/CodingAgentsStepTests.cs 🧪 Tests +128/-39

Add Cursor test cases to setup step tests

test/Kapacitor.Cli.Tests.Unit/CodingAgentsStepTests.cs


20. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookCommandTests.cs 🧪 Tests +160/-0

Comprehensive dispatcher behavior tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookCommandTests.cs


21. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookSpoolTests.cs 🧪 Tests +108/-0

Spool FIFO drain and eviction tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookSpoolTests.cs


22. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorTranscriptBackfillTests.cs 🧪 Tests +146/-0

Backfill watermark and budget tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorTranscriptBackfillTests.cs


23. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookEventMapTests.cs 🧪 Tests +31/-0

Event mapping and route resolution tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHookEventMapTests.cs


24. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorPathsIsInstalledTests.cs 🧪 Tests +61/-0

Cursor detection by directory presence tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorPathsIsInstalledTests.cs


25. test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHooksParserTests.cs 🧪 Tests +49/-0

Hooks.json entry parsing tests

test/Kapacitor.Cli.Tests.Unit/Cursor/CursorHooksParserTests.cs


26. test/Kapacitor.Cli.Tests.Unit/CursorHooksInstallerTests.cs 🧪 Tests +83/-0

Marker write/read and detection tests

test/Kapacitor.Cli.Tests.Unit/CursorHooksInstallerTests.cs


27. test/Kapacitor.Cli.Tests.Unit/CursorHooksWriterTests.cs 🧪 Tests +88/-0

Hooks.json install and remove tests

test/Kapacitor.Cli.Tests.Unit/CursorHooksWriterTests.cs


28. test/Kapacitor.Cli.Tests.Unit/PluginCommandCursorTests.cs 🧪 Tests +41/-0

Plugin command Cursor flag tests

test/Kapacitor.Cli.Tests.Unit/PluginCommandCursorTests.cs


29. test/Kapacitor.Cli.Tests.Unit/HttpClientExtensionsPostOnceTests.cs 🧪 Tests +77/-0

No-retry HTTP helper timeout tests

test/Kapacitor.Cli.Tests.Unit/HttpClientExtensionsPostOnceTests.cs


30. test/Kapacitor.Cli.Tests.Unit/PluginCommandSkillsTests.cs 🧪 Tests +2/-2

Update error message assertions for mutual exclusivity

test/Kapacitor.Cli.Tests.Unit/PluginCommandSkillsTests.cs


31. docs/superpowers/plans/2026-06-02-ai-730-cursor-hooks-dispatcher.md Additional files +2752/-0

...

docs/superpowers/plans/2026-06-02-ai-730-cursor-hooks-dispatcher.md


32. src/Kapacitor.Cli.Core/HttpClientExtensions.cs Additional files +31/-0

...

src/Kapacitor.Cli.Core/HttpClientExtensions.cs


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Jun 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0)

Grey Divider


Action required

1. Session ID path traversal ✓ Resolved 🐞 Bug ⛨ Security
Description
CursorHookSpool builds spool file paths directly from session_id from stdin, allowing path
traversal or absolute-path injection that can write/delete files outside the spool directory.
CursorHookCommand only strips hyphens from session_id, so path separators and rooted paths remain
possible.
Code

src/Kapacitor.Cli/Commands/CursorHookSpool.cs[R23-36]

Evidence
session_id is read from the stdin payload and passed into spool operations; the spool computes the
file path by directly embedding sessionId into the filename, with no sanitization beyond removing
hyphens.

src/Kapacitor.Cli/Commands/CursorHookCommand.cs[60-99]
src/Kapacitor.Cli/Commands/CursorHookCommand.cs[129-134]
src/Kapacitor.Cli/Commands/CursorHookSpool.cs[23-36]
src/Kapacitor.Cli/Commands/CursorHookSpool.cs[123-126]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CursorHookSpool.PathFor(sessionId)` uses `Path.Combine(_spoolDir, $"{sessionId}.jsonl")`. If `sessionId` contains path separators (`../`, `..\\`) or is rooted (`/etc/passwd`, `C:\\...`), .NET path resolution can escape `_spoolDir`, enabling arbitrary file write/delete under the kapacitor user.

## Issue Context
`session_id` comes from stdin JSON in the Cursor hook dispatcher. Current normalization only removes hyphens, not other unsafe characters.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/CursorHookCommand.cs[60-76]
- src/Kapacitor.Cli/Commands/CursorHookCommand.cs[129-134]
- src/Kapacitor.Cli/Commands/CursorHookSpool.cs[23-36]
- src/Kapacitor.Cli/Commands/CursorHookSpool.cs[123-126]

### Implementation notes
- Enforce a strict session id format before using it in any filesystem path (e.g., require dashless GUID: `Guid.TryParse` then convert to `N`, or regex `^[0-9a-f]{32}$` after normalization).
- If invalid, skip spool operations (drain/append/delete) for that invocation.
- Alternatively, derive the spool filename from a safe encoding (e.g., hex/base64url of SHA-256(sessionId)) and never use raw sessionId in the filename.
- Apply the same sanitized id consistently across spool operations.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Budget cancellation crashes hooks ✓ Resolved 🐞 Bug ☼ Reliability
Description
CursorHookCommand.HandleCore uses a 2-second CancellationToken but doesn’t catch
OperationCanceledException, so budget expiry can crash the hook process instead of returning 0
(fail-open). This token is passed into stdin read, spool drain, and transcript backfill, all of
which can throw on cancellation.
Code

src/Kapacitor.Cli/Commands/CursorHookCommand.cs[R45-51]

Evidence
HandleCore creates a budget CTS and uses its token for reading stdin and running spool/backfill;
both spool and backfill explicitly call cancellation-sensitive APIs (ThrowIfCancellationRequested,
ReadLineAsync(ct)), so budget expiry can throw and propagate without being handled.

src/Kapacitor.Cli/Commands/CursorHookCommand.cs[45-109]
src/Kapacitor.Cli/Commands/CursorHookSpool.cs[67-81]
src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs[55-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CursorHookCommand.HandleCore` creates a `CancellationTokenSource(budgetTotal)` and passes its token into multiple async operations (stdin read, spool drain, transcript backfill). When the 2s budget expires, these operations can throw `OperationCanceledException`, which currently bubbles out and can crash the hook process (breaking the intended fail-open behavior).

## Issue Context
The dispatcher is supposed to be bounded by a 2-second wall-clock budget and avoid blocking Cursor’s agent loop. Budget expiry should result in a clean `return 0`, not an unhandled exception.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/CursorHookCommand.cs[45-111]
- src/Kapacitor.Cli/Commands/CursorHookSpool.cs[67-81]
- src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs[55-63]

### Implementation notes
- Wrap the body of `HandleCore` in `try { ... } catch (OperationCanceledException) { return 0; }`.
- Consider also guarding against non-object JSON payloads up front (e.g., `if (node is not JsonObject obj) return 0;`) to avoid indexing exceptions.
- Ensure `CursorTranscriptBackfill.RunAsync` also fails open on cancellation (either catch inside `RunAsync` or rely on `HandleCore` catch).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Spool drain drops events ✓ Resolved 🐞 Bug ☼ Reliability
Description
CursorHookSpool.DrainAsync treats malformed JSON lines as “delivered” and can delete the spool file
after encountering them, permanently losing queued canonical events. This is especially risky
because Append and DrainAsync do not coordinate access, so concurrent append can produce a partially
written last line that becomes malformed when read.
Code

src/Kapacitor.Cli/Commands/CursorHookSpool.cs[R85-110]

Evidence
DrainAsync explicitly marks malformed lines as delivered and deletes the file if `delivered ==
lines.Length`; Append writes without any locking, making partial reads plausible under concurrent
invocation.

src/Kapacitor.Cli/Commands/CursorHookSpool.cs[25-36]
src/Kapacitor.Cli/Commands/CursorHookSpool.cs[85-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During drain, if a line cannot be parsed (or is missing required fields), the code advances `delivered = i + 1` and continues, and may delete the entire file at the end. If the malformed line is due to a partial concurrent append (or transient corruption), this logic drops events permanently.

## Issue Context
Spool is intended to preserve canonical events when POST fails, so lossy behavior undermines reliability guarantees.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/CursorHookSpool.cs[25-36]
- src/Kapacitor.Cli/Commands/CursorHookSpool.cs[74-111]

### Implementation notes
- If JSON parsing fails for a line, stop draining and leave the file untouched (or at least keep the malformed line and anything after it).
- Consider using an OS-level lock / mutex per session file so `Append` and `DrainAsync` can’t interleave destructively.
- If you must handle partial last-line reads, detect whether the file ends with a newline and treat a non-newline-terminated last line as incomplete (do not mark delivered / do not delete).
- Avoid deleting the spool file unless you are confident all entries were successfully delivered and the file contained no malformed/incomplete tail.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Backfill skips budget checks ✓ Resolved 🐞 Bug ➹ Performance
Description
CursorTranscriptBackfill.RunAsync does not check the dispatcher budget while skipping lines up to
resumeFrom, so a large resumeFrom can consume most/all of the 2-second budget just scanning old
transcript content. This increases hook latency even when no new lines are posted.
Code

src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs[R59-62]

Evidence
The budget check happens only after reaching resumeFrom; skipping to resumeFrom can still
require reading many lines. The dispatcher’s overall budget is 2 seconds.

src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs[57-63]
src/Kapacitor.Cli/Commands/CursorHookCommand.cs[19-49]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When `resumeFrom` is large, the backfill loop reads and discards many lines before ever calling `budget()`. This can burn the dispatcher’s limited wall-clock budget on resume scanning.

## Issue Context
This is mostly a performance/latency concern. The JSONL format requires sequential scanning, but we can still check budget while skipping.

## Fix Focus Areas
- src/Kapacitor.Cli/Commands/CursorTranscriptBackfill.cs[57-63]

### Implementation notes
- Check `budget()` at the top of the loop (before the resumeFrom skip), or at least periodically during skipping (e.g., every N lines) to bound scanning time.
- Optionally, check `budget()` before each `ReadLineAsync` call to avoid waiting on I/O when the budget is already exhausted.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Kapacitor.Cli/Commands/CursorHookCommand.cs Outdated
Comment thread src/Kapacitor.Cli/Commands/CursorHookSpool.cs Outdated
Comment thread src/Kapacitor.Cli/Commands/CursorHookSpool.cs
alexeyzimarev and others added 11 commits June 2, 2026 17:01
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>
@alexeyzimarev
alexeyzimarev merged commit 2f67dfd into main Jun 2, 2026
4 checks passed
@alexeyzimarev
alexeyzimarev deleted the alexeyzimarev/ai-730-cli-cursor-hooks-dispatcher-and-setup-wiring branch June 2, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant