Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .cursor/hooks.json
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 31 additions & 2 deletions cmd/entire/cli/agent/cursor/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
73 changes: 73 additions & 0 deletions cmd/entire/cli/agent/cursor/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 11 additions & 2 deletions cmd/entire/cli/agent/cursor/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/agent/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions cmd/entire/cli/hook_guard.go
Original file line number Diff line number Diff line change
@@ -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()
}
110 changes: 110 additions & 0 deletions cmd/entire/cli/hook_guard_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/entire/cli/hook_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading