From 7ffc4e9036926bf8c97abc6878dd96852d06097c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 01:01:15 +0000 Subject: [PATCH 1/2] feat(cursor): register for all 20 hook events from the current spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were registering only 4 of the 20 hook events Cursor exposes (https://cursor.com/docs/hooks), so most of the Agent + Tab flow was invisible to PromptConduit. Expand `buildCursorHooks` to cover the full set: Session lifecycle: sessionStart, sessionEnd Generic tool use (Agent): preToolUse, postToolUse, postToolUseFailure Subagent (Task tool): subagentStart, subagentStop Shell: beforeShellExecution, afterShellExecution MCP: beforeMCPExecution, afterMCPExecution File access (Agent): beforeReadFile, afterFileEdit Prompts and agent output: beforeSubmitPrompt, afterAgentResponse, afterAgentThought Context window: preCompact Stop: stop Tab (inline completions, separate policy from Agent): beforeTabFileRead, afterTabFileEdit We register both the generic `preToolUse`/`postToolUse` AND the specific-tool variants (`beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile`, `afterFileEdit`). The specific events carry richer payload (actual command / file path / MCP server name); the generic ones backfill any tool kind without a dedicated hook. Platform dedupes server-side by event id. `uninstall cursor` already recursively removes any hook entry whose value contains "promptconduit", so the new keys clean up without further changes — verified end-to-end against a fake HOME (20 hooks written, 20 removed). https://claude.ai/code/session_019CWBC2E8pQuShejfsKYrvp --- cmd/install.go | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index cae03f8..bca40cf 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -221,6 +221,18 @@ func installCursor(exePath string) error { return nil } +// buildCursorHooks registers for every event the current Cursor agent + Tab +// hooks spec exposes (https://cursor.com/docs/hooks). Agent hooks cover the +// Cmd+K / Agent Chat flow; Tab hooks cover inline-completion autonomous +// edits, which deliberately get a separate policy from user-directed Agent +// operations. +// +// We register both the generic `preToolUse`/`postToolUse` AND the +// specific-tool variants (`beforeShellExecution`, `beforeMCPExecution`, +// `beforeReadFile`, `afterFileEdit`). The specific events are richer +// (they carry the actual command / file path / MCP server), and the +// generic ones backfill any tool category that doesn't have a dedicated +// hook. The platform dedupes server-side by event id. func buildCursorHooks(hookCmd string) map[string]interface{} { makeHook := func() []map[string]interface{} { return []map[string]interface{}{ @@ -229,10 +241,37 @@ func buildCursorHooks(hookCmd string) map[string]interface{} { } return map[string]interface{}{ - "beforeSubmitPrompt": makeHook(), + // Session lifecycle + "sessionStart": makeHook(), + "sessionEnd": makeHook(), + // Generic tool use (fires for every tool, including the specific + // ones below; we keep both for coverage of unknown tool kinds) + "preToolUse": makeHook(), + "postToolUse": makeHook(), + "postToolUseFailure": makeHook(), + // Subagent (Task tool) lifecycle + "subagentStart": makeHook(), + "subagentStop": makeHook(), + // Shell command execution "beforeShellExecution": makeHook(), "afterShellExecution": makeHook(), - "afterFileEdit": makeHook(), + // MCP tool execution + "beforeMCPExecution": makeHook(), + "afterMCPExecution": makeHook(), + // File access and edits + "beforeReadFile": makeHook(), + "afterFileEdit": makeHook(), + // Prompts and agent output + "beforeSubmitPrompt": makeHook(), + "afterAgentResponse": makeHook(), + "afterAgentThought": makeHook(), + // Context window + "preCompact": makeHook(), + // Stop + "stop": makeHook(), + // Tab (inline completions) — separate policy from Agent operations + "beforeTabFileRead": makeHook(), + "afterTabFileEdit": makeHook(), } } From 8b9f818d4003a993c17130ed08cd2e56d9e950e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 01:06:15 +0000 Subject: [PATCH 2/2] feat(claude-code): full hook coverage + remove buggy WorktreeCreate registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to buildClaudeCodeHooks, plus an idempotency fix to install: 1. Add the 3 events from the current spec we weren't registering: - Setup (--init-only / -p --init / -p --maintenance) - UserPromptExpansion (slash-command expansion — covers /skillname direct invocations, a path PreToolUse does not capture) - PostToolBatch (fires once per resolved batch of parallel tool calls) 2. Remove WorktreeCreate. Per the canonical reference, configuring a WorktreeCreate hook *replaces* the default `git worktree` behavior — the hook must print the new worktree path on stdout, and Claude Code uses that as the working directory for the isolated session. Our generic hook handler always prints `{"continue": true}`, which would cause `claude --worktree` and `isolation: "worktree"` subagents to fail outright. Until our handler can detect WorktreeCreate events and return the correct path, we don't register for that event. (Source: https://code.claude.com/docs/en/hooks) Also tidies up: - Matchers dropped from no-matcher events (TaskCreated, TaskCompleted, TeammateIdle, PostToolBatch). The spec says matchers on those are silently ignored; we were just emitting noisy JSON. - install is now self-healing: before merging the current hook set into the user's settings.json, it strips any of OUR previously- installed entries (anything whose value contains "promptconduit"). That means an old WorktreeCreate registration left behind by a prior install gets cleaned up on re-install. Verified against a fake HOME: planted stale WorktreeCreate + a user-owned hook, re-installed, stale entry gone, user hook preserved. Net coverage: 28 of 29 events from the current spec (the missing one is WorktreeCreate, by design). https://claude.ai/code/session_019CWBC2E8pQuShejfsKYrvp --- cmd/install.go | 92 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index bca40cf..23debb7 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -81,8 +81,17 @@ func installClaudeCode(exePath string) error { hookCmd := fmt.Sprintf("%s hook", exePath) hooks := buildClaudeCodeHooks(hookCmd) - // Merge hooks into settings + // Strip any of OUR previously-installed hook entries first so events we + // no longer ship (e.g. WorktreeCreate, which we used to register but now + // deliberately skip) get cleaned up rather than lingering forever. Any + // hook whose value doesn't reference "promptconduit" is left alone — + // that's the user's own configuration. if existingHooks, ok := settings["hooks"].(map[string]interface{}); ok { + for name, config := range existingHooks { + if containsPromptConduit(config) { + delete(existingHooks, name) + } + } for name, config := range hooks { existingHooks[name] = config } @@ -112,6 +121,20 @@ func installClaudeCode(exePath string) error { return nil } +// buildClaudeCodeHooks registers for every event in the current Claude Code +// hooks reference (https://code.claude.com/docs/en/hooks) except for +// WorktreeCreate. Configuring a WorktreeCreate hook *replaces* the default +// `git worktree` behavior — Claude Code expects the hook to print the +// new worktree path on stdout, but our hook handler always prints +// `{"continue": true}`, which would cause `claude --worktree` and +// `isolation: "worktree"` subagents to fail outright. Until our hook +// handler can detect WorktreeCreate events and behave correctly (return +// the path), we don't register for that event. +// +// Matchers on no-matcher events (TaskCreated, TaskCompleted, TeammateIdle, +// PostToolBatch, etc.) are silently ignored per the spec, so we use +// `makeHook` for those rather than `makeMatcherHook` to keep the +// settings.json output tidy. func buildClaudeCodeHooks(hookCmd string) map[string]interface{} { makeHook := func(timeout int) []map[string]interface{} { return []map[string]interface{}{ @@ -132,37 +155,44 @@ func buildClaudeCodeHooks(hookCmd string) map[string]interface{} { } } + plainEvent := func() []map[string]interface{} { + return []map[string]interface{}{{"hooks": makeHook(5000)}} + } + return map[string]interface{}{ // Session lifecycle - "SessionStart": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "SessionEnd": []map[string]interface{}{{"hooks": makeHook(5000)}}, + "SessionStart": plainEvent(), + "Setup": plainEvent(), // --init-only / -p --init / -p --maintenance + "SessionEnd": plainEvent(), // Per-turn events - "UserPromptSubmit": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "Stop": []map[string]interface{}{{"hooks": makeHook(5000)}}, // Agent completes response - "StopFailure": []map[string]interface{}{{"hooks": makeHook(5000)}}, // Turn ends due to API error - // Tool execution events + "UserPromptSubmit": plainEvent(), + "UserPromptExpansion": plainEvent(), // slash-command expansion path + "Stop": plainEvent(), // Agent completes response + "StopFailure": plainEvent(), // Turn ends due to API error + // Tool execution events (matchers filter on tool name) "PreToolUse": makeMatcherHook(5000), "PostToolUse": makeMatcherHook(5000), "PostToolUseFailure": makeMatcherHook(5000), + "PostToolBatch": plainEvent(), // no matcher support — fires once per batch "PermissionRequest": makeMatcherHook(5000), "PermissionDenied": makeMatcherHook(5000), // Auto mode denies a tool call // Agent and task events - "SubagentStart": makeMatcherHook(5000), + "SubagentStart": makeMatcherHook(5000), // matcher filters on agent type "SubagentStop": makeMatcherHook(5000), - "TaskCreated": makeMatcherHook(5000), - "TaskCompleted": makeMatcherHook(5000), - "TeammateIdle": makeMatcherHook(5000), + "TaskCreated": plainEvent(), // no matcher support + "TaskCompleted": plainEvent(), // no matcher support + "TeammateIdle": plainEvent(), // no matcher support // File and configuration events - "InstructionsLoaded": []map[string]interface{}{{"hooks": makeHook(5000)}}, // CLAUDE.md loaded - "ConfigChange": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "CwdChanged": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "FileChanged": []map[string]interface{}{{"hooks": makeHook(5000)}}, + "InstructionsLoaded": plainEvent(), // CLAUDE.md / .claude/rules/*.md loaded + "ConfigChange": plainEvent(), + "CwdChanged": plainEvent(), + "FileChanged": plainEvent(), // Context compaction events - "PreCompact": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "PostCompact": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "WorktreeCreate": []map[string]interface{}{{"hooks": makeHook(5000)}}, - "WorktreeRemove": []map[string]interface{}{{"hooks": makeHook(5000)}}, - // MCP events + "PreCompact": plainEvent(), + "PostCompact": plainEvent(), + // Worktree (only Remove — see Create note in function comment) + "WorktreeRemove": plainEvent(), + // MCP events (matcher filters on MCP server name) "Elicitation": makeMatcherHook(5000), "ElicitationResult": makeMatcherHook(5000), // Notifications @@ -190,8 +220,17 @@ func installCursor(exePath string) error { hookCmd := fmt.Sprintf("%s hook", exePath) hooks := buildCursorHooks(hookCmd) - // Merge hooks into settings + // Strip any of OUR previously-installed hook entries first so events we + // no longer ship (e.g. WorktreeCreate, which we used to register but now + // deliberately skip) get cleaned up rather than lingering forever. Any + // hook whose value doesn't reference "promptconduit" is left alone — + // that's the user's own configuration. if existingHooks, ok := settings["hooks"].(map[string]interface{}); ok { + for name, config := range existingHooks { + if containsPromptConduit(config) { + delete(existingHooks, name) + } + } for name, config := range hooks { existingHooks[name] = config } @@ -295,8 +334,17 @@ func installGemini(exePath string) error { hookCmd := fmt.Sprintf("%s hook", exePath) hooks := buildGeminiHooks(hookCmd) - // Merge hooks into settings + // Strip any of OUR previously-installed hook entries first so events we + // no longer ship (e.g. WorktreeCreate, which we used to register but now + // deliberately skip) get cleaned up rather than lingering forever. Any + // hook whose value doesn't reference "promptconduit" is left alone — + // that's the user's own configuration. if existingHooks, ok := settings["hooks"].(map[string]interface{}); ok { + for name, config := range existingHooks { + if containsPromptConduit(config) { + delete(existingHooks, name) + } + } for name, config := range hooks { existingHooks[name] = config }