fix(relay-node): let Cursor editor chats reply and wake after Cursor-started turns
What this fixes
Three bugs that together meant a Cursor editor chat on the relay could neither answer a Relay message nor be woken by one while idle.
1. Replies pointed at a tool that can never send as a Cursor chat.
Cloud's hosted MCP requires every call made with a Desktop-installed provider token to prove which native chat it comes from. It can verify Codex, Claude, OpenCode, Grok, Pi and Copilot chats, but not Cursor: Cursor's MCP calls carry no chat id. So send_relay_message from any Cursor chat fails with "Agent Relay cannot verify this chat's desktop identity". A Cursor session that registered with "mcp":"agent-relay-sessions" was still shown only that tool, plus the route line saying not to send any other way.
relay_mcp_accessnow treats every Cursor session (SessionKind::CursorCli, editor and CLI) as socket-only, whatever it declared.needs_probe_clientnow gives a Cursor editor conversation the probe client on macOS. Its tools are identified byCURSOR_CONVERSATION_ID, which Apple's curl hides, so a curl reply was refused asnot_a_relay_session. A Cursor CLI session (identified by itscursor-agent) keeps curl.
2. An idle chat was not woken after a turn Cursor started itself.
Cursor runs no beforeSubmitPrompt for a turn it starts, such as the "background task finished" notification turn. So <id>.turn kept naming the turn before, and that turn's waiting stop hook saw itself as stale and retired at once. Nothing was waiting while the chat sat idle.
wait::untracked_turn_seenmakes a completed editorstop, under a proven window caller, name its own turn as current when.turnknows it neither as the current turn nor as an earlier one.- It leaves
.turnalone if the current turn was named after this hook started, or less than 2 s before; that turn may be the newer one. - It also leaves
.turnalone if the file can't be read. That keeps the existing fail-safe that no hook waits while the current turn is unknown.
3. A new chat couldn't put itself on the relay during its first turn.
A hook that doesn't name its conversation may only claim one whose transcript Cursor wrote recently, and Cursor writes the transcript late in the first turn. On top of that, editor_caller refused any tool whose conversation had no record yet. "Put yourself on the relay" therefore failed with not_a_relay_session in a fresh chat.
editor_callernow claims througheditor::claimfor a tool that names its conversation under a verified extension host.- This adds no new capability: a tool could already claim the same way by piping a payload to
agent-relay-probe cursor-hook, which vouches by name too. - Every other
may_claimcheck still applies: a conversation acursor-agentholds, one another running window owns, and a chain with an unreadable environment all name nobody.
Evidence (conversation b72f559d, today)
- Missing turn start. My manual
cursor-hookcall at 6:40 PM cleared.turn. Of the four turns since, the next three recorded a start, so.turnheld the current turn plus two earlier ones. It would hold three if the 6:40 PM notification turn had firedbeforeSubmitPrompt. - No wake. A message sent while the chat was idle at about 6:52 PM was only handed over at the next tool call, at 6:54 PM.
- Hosted send fails, socket send works. Hosted
send_relay_messagefailed with the desktop-identity error, whileagent-relay-probe send --socket …from the same chat delivered (message245384056523141120).
Relation to #2121
#2121 (Cursor CLI wake) adds followup_turn_seen for CLI follow-up turns and documents that "the editor names every turn as it starts". This PR shows that assumption is wrong for turns Cursor starts itself.
The two PRs touch neighbouring lines in wait.rs (module docs, the function after turn_seen) and run_hook's editor branch, so whichever merges second needs a small rebase. Once both land, the CLI follow-up rule and this editor rule could become one "a completed stop of an untracked turn names it" rule.
Review
- Adversarial pass.
- Trust boundary unchanged; see point 3.
- Turn naming runs only after
record_editor_hookproved the window caller, and inside the per-conversation waiting lock. - A missing, unreadable, or future-dated
.turnfails safe. - Residual race: a hook that takes more than 2 s from spawn to start while a newer turn starts in that gap. It's also still true that between a Cursor-started turn's start and its first hook, an older waiter can answer; that existed before this PR and no hook can close it.
- Contextual pass.
- It matches #2121's approach for the CLI and keeps
.turnfail-safe. - No docs describe the old rules.
- The changelog gets three
Fixedentries.
- It matches #2121's approach for the CLI and keeps
Validation
cargo testincrates/relay-node: 2360 lib tests plus all integration test binaries pass.cargo check --locked --no-default-features --tests,scripts/check-open-source.shandcargo fmt --checkall pass.- Clippy shows nothing new in the touched code; the warnings it lists are existing test case-table types.
- I reverted each fix in turn and confirmed its new test fails:
- the hosted-MCP override:
a_cursor_session_is_handed_the_socket_whatever_it_declares - the probe client:
a_cursor_editor_tool_is_its_conversation_never_the_session_that_started_cursor - the
untracked_turn_seencall:a_turn_cursor_started_itself_waits_after_its_stop - the first-turn claim:
a_tool_of_the_editors_agent_is_the_conversation_its_environment_names - the 2 s guard:
a_stop_leaves_a_turn_named_since_its_hook_started_or_just_before - the unreadable-turn guard:
a_stop_never_replaces_a_current_turn_that_cannot_be_read
- the hosted-MCP override:
- Not run locally:
- Windows
cargo check:ring's C build needs MSVC. The touched code isn't platform-gated. - The ignored live editor wake test: it needs a Desktop build carrying this commit.
- Windows
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.