From fadc4a06a9091f4a32cc3489dc8d79f62934903d Mon Sep 17 00:00:00 2001 From: snowingfox <1503401882@qq.com> Date: Tue, 26 May 2026 15:39:03 +0800 Subject: [PATCH 1/2] Fix Cursor hook misattribution and add token usage support 1. Add transcript-owner guard in executeAgentHook to prevent Cursor sessions from being claimed by Claude Code when only .claude/settings.json is installed (fixes #1262). 2. Parse token fields (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) from Cursor's stop hook payload into Event.TokenUsage so Cursor sessions report non-zero token counts. 3. Prefer hook-provided TokenUsage over transcript-based calculation in handleLifecycleTurnEnd, since Cursor's JSONL transcript has no usage data. Co-authored-by: Cursor Entire-Checkpoint: 2a89e658287e --- cmd/entire/cli/agent/cursor/lifecycle.go | 33 +++++- cmd/entire/cli/agent/cursor/lifecycle_test.go | 73 ++++++++++++ cmd/entire/cli/agent/cursor/types.go | 13 ++- cmd/entire/cli/agent/event.go | 6 + cmd/entire/cli/hook_guard.go | 38 ++++++ cmd/entire/cli/hook_guard_test.go | 110 ++++++++++++++++++ cmd/entire/cli/hook_registry.go | 12 ++ cmd/entire/cli/lifecycle.go | 11 +- cmd/entire/cli/lifecycle_test.go | 51 ++++++++ 9 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 cmd/entire/cli/hook_guard.go create mode 100644 cmd/entire/cli/hook_guard_test.go diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 36760255a..6420f4d38 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -113,14 +113,43 @@ func (c *CursorAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent if err != nil { return nil, err } - return &agent.Event{ + event := &agent.Event{ Type: agent.TurnEnd, SessionID: raw.ConversationID, SessionRef: c.resolveTranscriptRef(ctx, raw.ConversationID, raw.TranscriptPath), Model: raw.Model, TurnCount: int(intFromJSON(raw.LoopCount)), Timestamp: time.Now(), - }, nil + } + event.TokenUsage = tokenUsageFromStop(raw) + return event, nil +} + +// tokenUsageFromStop converts the per-turn token fields in Cursor's stop hook +// payload into the framework-wide TokenUsage struct. Cursor reports +// input_tokens as the *total* input (cache_read + cache_write + fresh), so we +// derive the fresh-input portion here. Returns nil when no usable token fields +// are present (some Cursor versions / hook variants omit them entirely), +// signaling "no data" rather than "all zeros". +func tokenUsageFromStop(raw *stopHookInputRaw) *agent.TokenUsage { + totalInput := int(intFromJSON(raw.InputTokens)) + output := int(intFromJSON(raw.OutputTokens)) + if totalInput == 0 && output == 0 { + return nil + } + cacheRead := int(intFromJSON(raw.CacheReadTokens)) + cacheWrite := int(intFromJSON(raw.CacheWriteTokens)) + freshInput := totalInput - cacheRead - cacheWrite + if freshInput < 0 { + freshInput = 0 + } + return &agent.TokenUsage{ + InputTokens: freshInput, + CacheCreationTokens: cacheWrite, + CacheReadTokens: cacheRead, + OutputTokens: output, + APICallCount: 1, + } } func (c *CursorAgent) parseSessionEnd(ctx context.Context, stdin io.Reader) (*agent.Event, error) { diff --git a/cmd/entire/cli/agent/cursor/lifecycle_test.go b/cmd/entire/cli/agent/cursor/lifecycle_test.go index 3490ca536..54189e40c 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle_test.go +++ b/cmd/entire/cli/agent/cursor/lifecycle_test.go @@ -183,6 +183,79 @@ func TestParseHookEvent_TurnEnd(t *testing.T) { } } +// TestParseHookEvent_TurnEnd_PopulatesTokenUsage verifies that Cursor's stop +// hook payload — which carries token usage fields not present in the JSONL +// transcript — is converted into event.TokenUsage. The framework treats this +// as the canonical token-usage signal for Cursor sessions because the JSONL +// transcript has no usage data. +// +// Cursor reports input_tokens as the total (cache + fresh), so the derived +// input must subtract cache_read_tokens and cache_write_tokens to avoid +// double-counting. +func TestParseHookEvent_TurnEnd_PopulatesTokenUsage(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{ + "conversation_id": "tok-1", + "transcript_path": "/tmp/stop.jsonl", + "input_tokens": 5000, + "output_tokens": 200, + "cache_read_tokens": 4000, + "cache_write_tokens": 800 + }` + + event, err := ag.ParseHookEvent(context.Background(), HookNameStop, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.NotNil(t, event.TokenUsage, "stop hook with token fields must populate event.TokenUsage") + + require.Equal(t, 200, event.TokenUsage.InputTokens, "InputTokens = total_input - cache_read - cache_write = 5000-4000-800") + require.Equal(t, 4000, event.TokenUsage.CacheReadTokens) + require.Equal(t, 800, event.TokenUsage.CacheCreationTokens) + require.Equal(t, 200, event.TokenUsage.OutputTokens) + require.Equal(t, 1, event.TokenUsage.APICallCount) +} + +// TestParseHookEvent_TurnEnd_OmittedTokensYieldNil verifies that older Cursor +// versions / hook variants without token fields produce a nil TokenUsage so +// downstream code can distinguish "no data" from "all zeros". +func TestParseHookEvent_TurnEnd_OmittedTokensYieldNil(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{"conversation_id": "no-tok", "transcript_path": "/tmp/stop.jsonl"}` + + event, err := ag.ParseHookEvent(context.Background(), HookNameStop, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.Nil(t, event.TokenUsage, "TokenUsage must be nil when the hook payload reports no token fields") +} + +// TestParseHookEvent_TurnEnd_CacheLargerThanInputClampsToZero is a defensive +// check: if cache_read + cache_write exceeds input_tokens (likely a Cursor +// reporting bug), the derived fresh input is clamped to zero rather than +// going negative, since negative tokens are nonsensical for billing displays. +func TestParseHookEvent_TurnEnd_CacheLargerThanInputClampsToZero(t *testing.T) { + t.Parallel() + + ag := &CursorAgent{} + input := `{ + "conversation_id": "clamp", + "transcript_path": "/tmp/stop.jsonl", + "input_tokens": 100, + "output_tokens": 50, + "cache_read_tokens": 80, + "cache_write_tokens": 80 + }` + + event, err := ag.ParseHookEvent(context.Background(), HookNameStop, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.NotNil(t, event.TokenUsage) + require.Equal(t, 0, event.TokenUsage.InputTokens, "negative fresh-input must clamp to zero") +} + func TestParseHookEvent_SessionEnd(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/cursor/types.go b/cmd/entire/cli/agent/cursor/types.go index 2897028f3..8c818d3b7 100644 --- a/cmd/entire/cli/agent/cursor/types.go +++ b/cmd/entire/cli/agent/cursor/types.go @@ -55,6 +55,11 @@ type sessionStartRaw struct { // stopHookInputRaw is the JSON structure from Stop hooks. // IDE provides transcript_path; CLI sends null. // Both provide status and loop_count. +// +// Token fields (input_tokens, output_tokens, cache_read_tokens, +// cache_write_tokens) are reported per turn by recent Cursor versions and are +// the only authoritative source of token accounting — the JSONL transcript +// does not include usage data. type stopHookInputRaw struct { // common ConversationID string `json:"conversation_id"` @@ -67,8 +72,12 @@ type stopHookInputRaw struct { TranscriptPath string `json:"transcript_path"` // hook specific - Status string `json:"status"` - LoopCount json.Number `json:"loop_count"` + Status string `json:"status"` + LoopCount json.Number `json:"loop_count"` + InputTokens json.Number `json:"input_tokens"` // Total input tokens (includes cache portions) + OutputTokens json.Number `json:"output_tokens"` // Generated output tokens + CacheReadTokens json.Number `json:"cache_read_tokens"` // Tokens served from cache (subset of input_tokens) + CacheWriteTokens json.Number `json:"cache_write_tokens"` // Tokens written to cache (subset of input_tokens) } // sessionEndRaw is the JSON structure from SessionEnd hooks. diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 4de42e6e7..0671f9a19 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -142,6 +142,12 @@ type Event struct { ContextTokens int // Context window tokens used (e.g., Cursor PreCompact hook) ContextWindowSize int // Total context window size (e.g., Cursor PreCompact hook) + // TokenUsage carries per-turn token accounting reported by an agent hook + // directly (e.g., Cursor's Stop hook). Set when the hook payload contains + // authoritative token data that the JSONL transcript does not. Lifecycle + // handlers prefer this over transcript-based calculation when populated. + TokenUsage *TokenUsage + // Metadata holds agent-specific state that the framework stores and makes available // on subsequent events. Examples: Pi's activeLeafId, Cursor's is_background_agent. Metadata map[string]string diff --git a/cmd/entire/cli/hook_guard.go b/cmd/entire/cli/hook_guard.go new file mode 100644 index 000000000..053b818cf --- /dev/null +++ b/cmd/entire/cli/hook_guard.go @@ -0,0 +1,38 @@ +// hook_guard.go protects against cross-agent hook forwarding. Cursor IDE +// invokes any hook configured under .claude/settings.json or .cursor/hooks.json +// for the active session — when only one of those files is installed, the +// other agent's hook command receives the event. shouldSkipForwardedHook +// detects this by inspecting the transcript path: if it lives inside another +// registered agent's session directory, the firing agent is forwarded and +// must no-op so the session isn't claimed for the wrong agent (#1262). +package cli + +import ( + "context" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// shouldSkipForwardedHook reports whether the firing agent should ignore this +// event because the transcript path proves it belongs to a different +// registered agent. Returns false when: +// - event has no SessionRef (no signal — fail open) +// - SessionRef is not inside any registered agent's session directory +// - SessionRef belongs to the firing agent itself +// - the worktree root cannot be resolved (fail open; downstream +// handlers will surface the error) +func shouldSkipForwardedHook(ctx context.Context, ag agent.Agent, event *agent.Event) bool { + if ag == nil || event == nil || event.SessionRef == "" { + return false + } + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return false + } + owner, ok := agent.AgentForTranscriptPath(event.SessionRef, repoRoot) + if !ok { + return false + } + return owner.Name() != ag.Name() +} diff --git a/cmd/entire/cli/hook_guard_test.go b/cmd/entire/cli/hook_guard_test.go new file mode 100644 index 000000000..c259de3dc --- /dev/null +++ b/cmd/entire/cli/hook_guard_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// TestShouldSkipForwardedHook_TranscriptBelongsToOtherAgent verifies the +// cross-agent guard for hooks that arrive at the wrong agent. When Cursor IDE +// invokes Claude Code hooks (because .cursor/hooks.json is missing — see +// issue #1262), the hook payload's transcript_path is inside Cursor's session +// directory. The firing agent (claude-code) must skip dispatch so the session +// isn't claimed for the wrong agent. +func TestShouldSkipForwardedHook_TranscriptBelongsToOtherAgent(t *testing.T) { + setupStopTestRepo(t) + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + + cursorTranscript := filepath.Join(cursorDir, "abc-session", "abc-session.jsonl") + + claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) + require.NoError(t, err) + + event := &agent.Event{ + Type: agent.SessionStart, + SessionID: "abc-session", + SessionRef: cursorTranscript, + } + + require.True(t, + shouldSkipForwardedHook(context.Background(), claudeAgent, event), + "claude-code must skip: transcript_path is in Cursor's session dir") +} + +// TestShouldSkipForwardedHook_TranscriptBelongsToFiringAgent verifies the +// guard does not fire when the transcript path is in the firing agent's own +// session directory — that's the normal case (Cursor → cursor hook). +func TestShouldSkipForwardedHook_TranscriptBelongsToFiringAgent(t *testing.T) { + setupStopTestRepo(t) + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + + cursorTranscript := filepath.Join(cursorDir, "abc", "abc.jsonl") + + cursorAgent, err := agent.Get(agent.AgentNameCursor) + require.NoError(t, err) + + event := &agent.Event{ + Type: agent.SessionStart, + SessionID: "abc", + SessionRef: cursorTranscript, + } + + require.False(t, + shouldSkipForwardedHook(context.Background(), cursorAgent, event), + "cursor must not skip its own session") +} + +// TestExecuteAgentHook_SkipsWhenTranscriptPathBelongsToOtherAgent reproduces +// issue #1262: only .claude/settings.json is installed, so Cursor IDE invokes +// `entire hooks claude-code session-start` with a Cursor-shaped payload. The +// transcript_path inside Cursor's session dir proves the session is Cursor's, +// so executeAgentHook must short-circuit before SessionStart runs. Otherwise +// StoreAgentTypeHint would claim the session for claude-code. +func TestExecuteAgentHook_SkipsWhenTranscriptPathBelongsToOtherAgent(t *testing.T) { + setupStopTestRepo(t) + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(cwd, ".entire"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, ".entire", "settings.json"), + []byte(`{"enabled":true}`), + 0o644, + )) + + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + cursorTranscript := filepath.Join(cursorDir, "abc-session", "abc-session.jsonl") + + payload, err := json.Marshal(map[string]string{ + "session_id": "abc-session", + "transcript_path": cursorTranscript, + }) + require.NoError(t, err) + + cmd := &cobra.Command{} + cmd.SetIn(bytes.NewReader(payload)) + cmd.SetContext(context.Background()) + + require.NoError(t, executeAgentHook(cmd, agent.AgentNameClaudeCode, "session-start", false)) + + // State file must not exist — the guard skipped before SessionStart ran. + statePath := filepath.Join(cwd, ".git", "entire-sessions", "abc-session.json") + _, statErr := os.Stat(statePath) + require.True(t, os.IsNotExist(statErr), "session state must not be created when the hook is forwarded from another agent (got: %v)", statErr) + + // Agent hint file must not exist either — it's the precursor to AgentType=ClaudeCode. + hintPath := filepath.Join(cwd, ".git", "entire-sessions", "abc-session.agent") + _, hintErr := os.Stat(hintPath) + require.True(t, os.IsNotExist(hintErr), "agent hint must not be written when the hook is forwarded (got: %v)", hintErr) +} diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 0ca44bdae..a0617fcbf 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -152,6 +152,18 @@ func executeAgentHook(cmd *cobra.Command, agentName types.AgentName, hookName st } if event != nil { + // Cross-agent guard: when Cursor IDE invokes a hook configured under + // .claude/settings.json (because .cursor/hooks.json is missing), the + // hook payload's transcript_path proves the session belongs to Cursor. + // Skip dispatch so the session isn't claimed for the wrong agent (#1262). + if shouldSkipForwardedHook(ctx, ag, event) { + logging.Debug(ctx, "skipping forwarded hook: transcript belongs to another agent", + slog.String("hook", hookName), + slog.String("firing_agent", string(agentName)), + slog.String("session_ref", event.SessionRef), + ) + return nil + } // Lifecycle event — use the generic dispatcher hookErr = DispatchLifecycleEvent(ctx, ag, event) } else if agentName == agent.AgentNameClaudeCode && hookName == claudecode.HookNamePostTodo { diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index f62868caf..300a4e781 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -701,8 +701,15 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev transcriptLinesAtStart = preState.TranscriptOffset } - // Calculate token usage - prefer SubagentAwareExtractor to include subagent tokens - tokenUsage := agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + // Resolve token usage. Hook-provided counts (e.g., Cursor's stop hook, + // which is the only authoritative source for Cursor sessions because the + // JSONL transcript has no usage fields) take precedence; otherwise fall + // back to transcript-based computation, preferring SubagentAwareExtractor + // to include subagent tokens. + tokenUsage := event.TokenUsage + if tokenUsage == nil { + tokenUsage = agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + } // Build fully-populated step context and delegate to strategy stepCtx := strategy.StepContext{ diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index b1c2f5c69..6d5615de9 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -1098,6 +1098,57 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) { } } +// TestHandleLifecycleTurnEnd_PrefersEventTokenUsage verifies that when the +// hook payload reports per-turn token usage (e.g., Cursor's stop hook), +// the lifecycle handler uses those numbers verbatim instead of falling back +// to transcript-based computation. This is the only way Cursor sessions get +// non-zero token data, since Cursor's JSONL transcript has no usage fields. +func TestHandleLifecycleTurnEnd_PrefersEventTokenUsage(t *testing.T) { + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + // Modify a file so SaveStep actually runs. + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "init.txt"), []byte("changed"), 0o600)) + + transcriptPath := filepath.Join(tmpDir, "transcript.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, []byte(`{"type":"user","message":"test"}`+"\n"), 0o600)) + + sessionID := "test-prefer-event-tokens" + ag := newMockAgent() + ag.transcriptData = []byte(`{"type":"user","message":"test"}` + "\n") + + event := &agent.Event{ + Type: agent.TurnEnd, + SessionID: sessionID, + SessionRef: transcriptPath, + Timestamp: time.Now(), + TokenUsage: &agent.TokenUsage{ + InputTokens: 200, + CacheReadTokens: 4000, + CacheCreationTokens: 800, + OutputTokens: 50, + APICallCount: 1, + }, + } + + require.NoError(t, handleLifecycleTurnEnd(context.Background(), ag, event)) + + state, err := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.NotNil(t, state.TokenUsage, "session state TokenUsage must be populated from event.TokenUsage") + require.Equal(t, 200, state.TokenUsage.InputTokens, "InputTokens must match event-provided value, not transcript-derived") + require.Equal(t, 4000, state.TokenUsage.CacheReadTokens) + require.Equal(t, 800, state.TokenUsage.CacheCreationTokens) + require.Equal(t, 50, state.TokenUsage.OutputTokens) + require.Equal(t, 1, state.TokenUsage.APICallCount) +} + func TestHandleLifecycleTurnEnd_BackfillsPromptFromTranscript(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir() tmpDir := t.TempDir() From 1ae00e27bd4b71c616509b007eda5f8234c2ca13 Mon Sep 17 00:00:00 2001 From: snowingfox <1503401882@qq.com> Date: Tue, 26 May 2026 16:02:46 +0800 Subject: [PATCH 2/2] feat: add cursor hooks.json --- .cursor/hooks.json | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .cursor/hooks.json diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..e4695ddfd --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,40 @@ +{ + "hooks": { + "beforeSubmitPrompt": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor before-submit-prompt" + } + ], + "preCompact": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor pre-compact" + } + ], + "sessionEnd": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor session-end" + } + ], + "sessionStart": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor session-start" + } + ], + "stop": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor stop" + } + ], + "subagentStart": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor subagent-start" + } + ], + "subagentStop": [ + { + "command": "go run \"$(git rev-parse --show-toplevel)\"/cmd/entire/main.go hooks cursor subagent-stop" + } + ] + }, + "version": 1 +}