Skip to content

Fix Cursor session mis-identified as Claude Code#1082

Merged
Soph merged 10 commits intomainfrom
ashtom/cursor-fix
May 4, 2026
Merged

Fix Cursor session mis-identified as Claude Code#1082
Soph merged 10 commits intomainfrom
ashtom/cursor-fix

Conversation

@ashtom
Copy link
Copy Markdown
Member

@ashtom ashtom commented Apr 30, 2026

https://entire.io/gh/entireio/cli/trails/269

Summary

When Cursor Agent forwards a single user prompt to both .cursor/hooks.json and .claude/settings.json, two entire hook processes can claim the same session_id with different agent_types. The loser's wrong agent_type can win the last write — entire status then shows e.g. Claude Code (composer-2) for a Cursor session.

Two layered defenses + repair on the next turn:

  • SessionStart agent hint (first-writer-wins): the first agent to fire SessionStart writes <sessionID>.agent with O_CREATE|O_EXCL. Subsequent hooks for the same session see the existing file and no-op. InitializeSession reads this hint at TurnStart so the firing hook can't relabel a session that another agent already claimed.
  • Transcript-path override (strongest signal): at TurnStart, if the transcript path lives inside another agent's GetSessionDir(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.
  • Forwarded-event filter: once state.AgentType is recorded, lifecycle events fired by other agents are skipped (SessionStart and TurnStart are exempt — they need to reach the setup paths to perform repair).

Concurrent InitializeSession calls for the same session_id are accepted (no per-session lock — see e983e08a for the rationale: in field logs Cursor IDE only forwards SessionStart/Stop, not UserPromptSubmit, 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>.banner marker so a non-HookResponseWriter agent (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 fmt clean
  • mise run lint clean
  • All unit tests pass — added TestAgentForTranscriptPath, TestPathHasDirPrefix_CaseSensitivity (Windows-aware), TestStoreAgentTypeHint_*, TestClaimSessionStartBanner_*, TestResolveSessionAgentType_*, TestCorrectSessionAgentType_*, TestInitializeSession_HintWinsRaceAtTurnStart, TestInitializeSession_TranscriptPathRepairsExistingState, TestInitializeSession_ConcurrentCallsConvergeToTranscriptOwner (also under -race), TestHandleLifecycleSessionStart_NonWriterClaimDoesNotSuppressBanner, TestHandleLifecycleSessionStart_BannerClaimedOnce
  • mise run test:integration passes
  • mise run test:e2e:canary passes (4/4)
  • GOOS=windows go build ./... and go vet clean
  • Verified live in entire-helpers repo: existing wrong session repaired itself on the next prompt via transcript-path override
  • Verify on Linux/Windows (CI)

🤖 Generated with Claude Code

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
Copilot AI review requested due to automatic review settings April 30, 2026 09:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on SessionStart and consumed during TurnStart.
  • Resolve/repair session ownership using transcript-path ownership detection (AgentForTranscriptPath) and a corrective update path.
  • Serialize concurrent InitializeSession calls 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.

Comment thread cmd/entire/cli/strategy/session_state.go
Comment thread cmd/entire/cli/strategy/session_state.go Outdated
Comment thread cmd/entire/cli/agent/registry_test.go
ashtom and others added 7 commits April 30, 2026 12:15
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
@Soph Soph marked this pull request as ready for review April 30, 2026 20:41
@Soph Soph requested a review from a team as a code owner April 30, 2026 20:41
@Soph
Copy link
Copy Markdown
Collaborator

Soph commented Apr 30, 2026

@BugBot review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread cmd/entire/cli/lifecycle.go
Soph and others added 2 commits May 4, 2026 10:31
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
@Soph Soph enabled auto-merge May 4, 2026 09:51
@Soph Soph merged commit 23c5f51 into main May 4, 2026
9 checks passed
@Soph Soph deleted the ashtom/cursor-fix branch May 4, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants