fix(relay-node): wake idle Cursor CLI sessions with Relay messages

Merged
AG
4d ago
+715-184

Problem

A Relay message sent to an idle cursor-agent session (for example dog-pipe) was staged but never woke the session. It showed up only at the session's next tool call or prompt. Cursor editor chats were fixed earlier by a stop hook that waits after a completed turn, but CLI sessions still returned from that hook at once.

Change

  • The CLI stop hook waits. A Cursor CLI session's stop hook (cursor-hook --wait) now waits after a completed turn, like the editor's. It waits under the cursor-agent that holds the conversation's store.db (wait::Holder::Cli), and stops waiting once that process quits or switches to another conversation.
  • Follow-up turns are tracked. The CLI runs no beforeSubmitPrompt for a turn that a stop follow-up starts. Without a fix, the hook woke a session once and then never re-armed. A follow-up's stop (loop_count > 0) for a turn that is neither current nor earlier now names that turn current once the turn before it ended (wait::stop_seen, which the third commit below made one rule for the CLI and the editor).
  • No five-follow-up cap. The cap that was hard-coded for CLI sessions is gone, which matches the waiting entry's loop_limit: null.

Verification

Live testing used a real cursor-agent (2026.11.05-86047df) through the running Agent Relay Desktop core, with this branch's agent-relay-node cursor-hook entries added to ~/.cursor/hooks.json:

  • Three wakes in a row. A registered, idle CLI session was messaged three times in a row (KIWI, MANGO, LYCHEE). Each message was handed over 2 to 3 seconds after sending, and each reply came back over the relay. Before the follow-up fix, only the first wake worked.
  • A person types while the hook waits. The prompt runs at once, the old waiter retires, and a new one arms for the new turn.
  • Ctrl-C while the hook waits. The CLI exits in about 3 seconds, and the hook and its waiter file are cleaned up.
  • Headless mode. cursor-agent -p runs no hooks at all, so headless runs are never held open.
  • Tests. cargo test --locked passes for crates/relay-node (2,360 lib tests plus integration suites), and cargo fmt --check is clean. New unit tests cover a wake, a wake after a follow-up turn, a late older-turn stop, the agent quitting, and the agent switching conversation.

Known trade-off (same as the editor)

Cursor waits for every stop hook before it acts on any follow-up. While Agent Relay's hook is waiting, a followup_message returned by another user stop hook waits too, until a Relay message arrives or the wait ends after 59 minutes.

Second commit: Cursor editor chats reply and wake after Cursor-started turns

This was opened separately as #2122 and moved here because it touches the same turn tracking. It fixes three editor-side bugs.

  • Replies. Cloud's hosted MCP can't send as any Cursor chat, because Cursor's MCP calls don't say which chat they come from. A Cursor session that declared "mcp":"agent-relay-sessions" was still shown only that send tool, plus a line saying not to send any other way. relay_mcp_access now treats every Cursor session, editor and CLI, as socket-only. On macOS a Cursor editor conversation also gets the probe client (needs_probe_client), because Apple's curl hides CURSOR_CONVERSATION_ID and curl replies were refused as not_a_relay_session.
  • Editor turns Cursor starts itself. The editor runs no beforeSubmitPrompt for a turn it starts itself, such as the "background task finished" turn. So .turn kept naming the previous turn, and the waiting stop hook retired at once. Its stop now names that turn (wait::stop_seen, as revised in the third commit below), which corrects the note that "the editor names every turn as it starts".
  • A chat's first turn. A new chat couldn't put itself on the relay until Cursor wrote its transcript. editor_caller now claims a conversation for a tool that names it under a verified extension host. That was already possible by running cursor-hook from the same tool, and every other may_claim check still applies.

Evidence (editor conversation b72f559d):

  • .turn held two earlier turns after four turns. It would have held three if the 6:40 PM notification turn had run beforeSubmitPrompt.
  • A message sent while the chat was idle was only handed over at its next tool call.
  • Hosted send_relay_message failed with "cannot verify this chat's desktop identity", while agent-relay-probe send from the same chat delivered.

Validation on this branch:

  • cargo test for crates/relay-node passes: 2,363 lib tests plus all integration suites.
  • cargo check --locked --no-default-features --tests, scripts/check-open-source.sh and cargo fmt --check pass, and clippy reports nothing in the touched files.
  • Each new test fails when its fix is reverted. I re-ran this for the merged editor call site.

Residual risk. None from timing since the third commit, which removed the 2 s window.

Third commit: review fixes

This commit addresses the review threads on this PR and on #2122.

  • One clock-free rule for turns that run no beforeSubmitPrompt. Greptile found that a follow-up's late stop could replace a prompt submitted after it. CodeRabbit found that the editor's 2 s file-time window broke if the clock stepped back, and missed background turns that finished quickly. The fix:
    • followup_turn_seen and untracked_turn_seen are replaced by wait::stop_seen. Every proven stop marks its own turn as ended in <id>.turn.
    • A stop of a turn .turn doesn't know names that turn as current only once the current turn has ended, because Cursor starts a turn only after the previous one ended.
    • A prompt submitted in between hasn't ended, so it stays current.
    • Missing, unreadable or older .turn files still fail closed until the next prompt. .turn stays at version 1, and files without the flag read as not ended.
  • A second cursor-agent on the same conversation. Devin, Greptile and CodeRabbit all found that a waiting CLI hook kept claiming after another cursor-agent resumed its conversation. Before each claim, Waiting::shared now checks the same one-holder rule as hook_caller. If another holder exists, the hook ends Moved and the message stays pending.
  • Channel and Connect replies. Devin found that a Cursor editor chat on macOS was handed curl for channel replies, channel subscriptions and Relay Connect replies. These now use the probe, as its direct-message replies do.

Validation:

  • cargo test for crates/relay-node passes: 2,367 lib tests plus all integration suites.
  • cargo check --locked --no-default-features --tests, scripts/check-open-source.sh and cargo fmt --check pass.
  • Reverting each fix makes its new test fail (nine mutations).

Not changed, with reasons in their threads:

  • Other stop hooks' follow-ups wait for this one; see the known trade-off above.
  • A tool can claim a conversation no window owns yet. This was already possible through cursor-hook.
  • A failed .turn write stays failed closed until the next prompt.

Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.