Fix Cursor session mis-identified as Claude Code#1082
Conversation
When Cursor IDE forwards a single user prompt to both .cursor/hooks.json and .claude/settings.json, two `entire` hook processes race to claim the session and the loser's wrong agent_type can win the last write. Three layered defenses: - SessionStart hint: first agent to fire claims the session ID (`<sessionID>.agent` written with O_EXCL). - Transcript-path override: a transcript inside an agent's session dir is authoritative — overrides both hint and the firing hook. - Per-session file lock: serializes concurrent InitializeSession calls so the runner-up takes the cheap update path (`correctSessionAgentType`) instead of also creating-from-scratch. Lock files live under `<git-common-dir>/entire-locks/` to keep `entire-sessions/` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: e51b741d6a56
There was a problem hiding this comment.
Pull request overview
This PR addresses a race where Cursor and Claude Code hooks can both initialize the same session, causing the persisted AgentType to intermittently reflect the wrong agent in entire status. It adds multiple signals and serialization so concurrent hook processes converge on the correct session owner.
Changes:
- Add a first-writer-wins agent-type hint file (
<sessionID>.agent) written onSessionStartand consumed duringTurnStart. - Resolve/repair session ownership using transcript-path ownership detection (
AgentForTranscriptPath) and a corrective update path. - Serialize concurrent
InitializeSessioncalls via a per-session cross-platform file lock stored under<git-common-dir>/entire-locks/.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| cmd/entire/cli/strategy/session_state.go | Adds per-session lock acquisition and agent-type hint read/write/cleanup logic. |
| cmd/entire/cli/strategy/manual_commit_hooks.go | Resolves the owning agent at InitializeSession and corrects stored AgentType based on transcript-path evidence. |
| cmd/entire/cli/agent/registry.go | Adds transcript-path → agent ownership resolution via session-dir prefix matching. |
| cmd/entire/cli/lifecycle.go | Stores agent-type hint during SessionStart to establish first-writer ownership. |
| cmd/entire/cli/filelock/filelock.go | Introduces a small cross-platform exclusive file lock abstraction. |
| cmd/entire/cli/filelock/filelock_unix.go | Unix implementation using flock. |
| cmd/entire/cli/filelock/filelock_windows.go | Windows implementation using LockFileEx. |
| cmd/entire/cli/strategy/session_state_test.go | Adds tests for agent-type hint storage/behavior and cleanup. |
| cmd/entire/cli/strategy/agent_resolution_test.go | Adds tests for agent resolution priority and concurrent initialization convergence. |
| cmd/entire/cli/lifecycle_test.go | Adds tests ensuring SessionStart stores the agent-type hint and first-writer-wins behavior. |
| cmd/entire/cli/filelock/filelock_test.go | Adds serialization/idempotent release tests for the lock implementation. |
| cmd/entire/cli/agent/registry_test.go | Adds tests for transcript-path agent ownership and prefix-collision handling. |
acquireImpl returns lockImpl so Acquire doesn't need build-tag-conditional code. Same pattern as the agent package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: e4815943e53a
Gemini fires SessionStart twice on launch (once for source=startup, then for source=resume after restoring saved chat state). Both fired our hook; both emitted the "Entire CLI will link this conversation..." banner. StoreAgentTypeHint now returns (created bool, err error) — true only when the call wrote the hint file (O_EXCL). Lifecycle gates the banner on created=true, so subsequent SessionStarts for the same session ID skip the banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 2e86dcafd179
Gemini CLI v0.40.0 double-displays JSON systemMessage from hook output: emitHookSystemMessage adds an info entry tagged [hookName], and the SessionStart caller also calls historyManager.addItem with the same text without a tag — so the user sees the banner twice. The previous sessionId-dedup attempt didn't help because both displays come from a single hook invocation. Switching to plain-text output bypasses the JSON branch: gemini's convertPlainTextToHookOutput synthesizes systemMessage internally, the JSON-only emitHookSystemMessage event doesn't fire, and only the historyManager.addItem path runs. Banner displays once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: b970ac3f423b
The InitializeSession race the lock guarded doesn't fire in practice — field logs from a Cursor IDE session show only Cursor's TurnStart fires (beforeSubmitPrompt is not forwarded to .claude/settings.json), and correctSessionAgentType already repairs wrong AgentType from the transcript path on the next turn. Removing it eliminates a generic synchronization primitive that future code could expand, plus the cleanup race in ClearSessionState where unlinking a held .lock file orphans the inode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 14da5eb72d51
Cursor IDE forwards stop hooks to both .cursor/hooks.json and .claude/settings.json (verified in field logs), so without filtering the non-owning agent's process duplicates checkpoint creation, races on metadata writes, and emits "transcript flush sentinel not found" warnings — Claude Code's TurnEnd handler waits for a sentinel in Cursor's transcript that will never appear. Once SessionState records the owning agent, the dispatcher no-ops events from any other agent for that session_id. SessionStart bypasses the check (state doesn't exist yet; hint dedup handles its duplication). TurnStart bypasses too, so InitializeSession's transcript-path resolution can repair a wrongly-set AgentType. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 35ba11dcdbb3
Extract eventBypassesAgentOwnershipCheck helper, reuse setupStopTestRepo across tests, switch to typed agent constants (agent.AgentTypeCursor / ClaudeCode / Gemini) so the cast wrappers go away, and strengthen AllowsTurnStartFromMismatchedAgent to assert dispatch actually ran by checking that TurnID was generated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 17fd2ef20bd8
Unit tests: a table-driven test exercises every non-bypass event type (TurnEnd, Compaction, SubagentStart/End, ModelUpdate, SessionEnd) and asserts the skip via observable side effects (EndedAt nil, ModelName unchanged). Adds negative cases for owner-match and empty-AgentType, and a direct test of eventBypassesAgentOwnershipCheck. Integration tests: TestDispatcher_ForwardedStopFromNonOwnerIsSkipped and TestDispatcher_ForwardedSessionEndFromNonOwnerIsSkipped invoke `entire hooks claude-code stop` / `entire hooks claude-code session-end` on a Cursor-owned session and verify state is unchanged — proves the skip works end-to-end through the real CLI binary, not just the in-process DispatchLifecycleEvent function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: e6e214d0a9eb
|
@BugBot review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 9f34898. Configure here.
When Cursor and Claude Code both fire SessionStart for the same session (Cursor IDE forwards hooks to .cursor/hooks.json and .claude/settings.json), StoreAgentTypeHint's first-writer-wins claim was gating both ownership AND banner display. Cursor doesn't implement HookResponseWriter, so when it won the race (~50%), it consumed the banner privilege without printing one, and Claude Code's banner was then suppressed too. Add ClaimSessionStartBanner with its own marker file, and only call it from inside the HookResponseWriter branch. Ownership claim semantics (used by InitializeSession repair and the dispatcher skip) are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 1d854d8d0b19
NTFS/ReFS treat paths as case-insensitive, and filepath.Abs preserves whatever casing the input had. A transcript path like C:\Users\Bob\.cursor\... and a session dir like c:\users\bob\.cursor\... refer to the same location but would not match under HasPrefix, silently dropping the strongest signal in AgentForTranscriptPath. Lowercase both sides on Windows; keep Unix case-sensitive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 7212dd03c385

https://entire.io/gh/entireio/cli/trails/269
Summary
When Cursor Agent forwards a single user prompt to both
.cursor/hooks.jsonand.claude/settings.json, twoentirehook processes can claim the samesession_idwith differentagent_types. The loser's wrongagent_typecan win the last write —entire statusthen shows e.g.Claude Code (composer-2)for a Cursor session.Two layered defenses + repair on the next turn:
SessionStartwrites<sessionID>.agentwithO_CREATE|O_EXCL. Subsequent hooks for the same session see the existing file and no-op.InitializeSessionreads this hint at TurnStart so the firing hook can't relabel a session that another agent already claimed.TurnStart, if the transcript path lives inside another agent'sGetSessionDir(repo), that agent wins regardless of which hook is firing or what the hint says. This same check (correctSessionAgentType) also runs each turn against existing sessions, so a session that was mis-attributed on the racing turn is corrected the moment a later turn's transcript points to the true owner.state.AgentTypeis recorded, lifecycle events fired by other agents are skipped (SessionStartandTurnStartare exempt — they need to reach the setup paths to perform repair).Concurrent
InitializeSessioncalls for the same session_id are accepted (no per-session lock — seee983e08afor the rationale: in field logs Cursor IDE only forwardsSessionStart/Stop, notUserPromptSubmit, so the race doesn't actually fire in practice; the transcript-path repair handles any case where it does).The SessionStart banner is gated by a separate
<sessionID>.bannermarker so a non-HookResponseWriteragent (Cursor) winning the ownership race can't suppress the banner from a banner-capable agent firing SessionStart for the same session (e38f1c60).Test plan
mise run fmtcleanmise run lintcleanTestAgentForTranscriptPath,TestPathHasDirPrefix_CaseSensitivity(Windows-aware),TestStoreAgentTypeHint_*,TestClaimSessionStartBanner_*,TestResolveSessionAgentType_*,TestCorrectSessionAgentType_*,TestInitializeSession_HintWinsRaceAtTurnStart,TestInitializeSession_TranscriptPathRepairsExistingState,TestInitializeSession_ConcurrentCallsConvergeToTranscriptOwner(also under-race),TestHandleLifecycleSessionStart_NonWriterClaimDoesNotSuppressBanner,TestHandleLifecycleSessionStart_BannerClaimedOncemise run test:integrationpassesmise run test:e2e:canarypasses (4/4)GOOS=windows go build ./...andgo vetcleanentire-helpersrepo: existing wrong session repaired itself on the next prompt via transcript-path override🤖 Generated with Claude Code