From 319c4b54a1461081bcb7d176d99e0921a130f22e Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Sun, 8 Nov 2026 18:23:24 -0800 Subject: [PATCH 1/3] fix(relay-node): wake idle Cursor CLI sessions with Relay messages The Cursor CLI's stop hook answered at once, so a cursor-agent idle at its prompt kept a staged message until its next tool call or prompt. It now waits after a completed turn like the editor's, under the cursor-agent that holds the conversation, and stops once that process quits or switches conversation. The CLI runs no beforeSubmitPrompt for a stop follow-up's turn, so that turn's stop names it current, letting the hook wait again after every wake. Cursor's five-follow-up cap no longer applies to CLI sessions, matching the waiting entry's loop_limit: null. Co-authored-by: Cursor --- CHANGELOG.md | 1 + crates/relay-node/src/cli/relay_commands.rs | 5 +- crates/relay-node/src/relay/cli.rs | 5 +- crates/relay-node/src/relay/cursor_cli.rs | 47 +++-- .../relay-node/src/relay/cursor_cli/inbox.rs | 2 +- .../relay-node/src/relay/cursor_cli/tests.rs | 174 ++++++++++++++---- .../relay-node/src/relay/cursor_cli/wait.rs | 143 ++++++++++---- .../src/relay/cursor_cli/wait_tests.rs | 19 +- 8 files changed, 285 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1104c6c56..e696099bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `POST /copilot/mcp` no longer returns an unknown-route error during native Copilot initialization: it uses Desktop's managed transport when available and otherwise reports the transport as unavailable. - `agent-relay-node` built for its own site (`AGENT_RELAY_SITE_URL`) no longer falls back to Agent Relay's public gateways: a sign-in whose site named no valid Relaycast URL, or a CLI workspace key with no recorded server or `RELAY_BASE_URL`, is refused with a reason naming the site, and `RELAYCAST_URL` is ignored in such a build. - `agent-relay-node` lists Codex sessions and delivers messages to them promptly when Codex's thread database is large. +- Relay messages now wake an idle interactive Cursor CLI (`cursor-agent`) session within seconds, as they already did for Cursor editor chats, instead of waiting for its next tool call or prompt. This keeps working after several automatic follow-ups in a row. ## [17.2.1] - 2026-11-07 diff --git a/crates/relay-node/src/cli/relay_commands.rs b/crates/relay-node/src/cli/relay_commands.rs index 24b2285ec..3540851d7 100644 --- a/crates/relay-node/src/cli/relay_commands.rs +++ b/crates/relay-node/src/cli/relay_commands.rs @@ -220,8 +220,9 @@ pub enum RelayCommands { /// Read Agent Relay Dev's inbox instead of Agent Relay's. #[arg(long)] dev: bool, - /// At the end of a Cursor editor conversation's completed turn, - /// wait for its next message instead of answering at once. + /// At the end of a Cursor editor conversation's or CLI session's + /// completed turn, wait for its next message instead of answering at + /// once. #[arg(long)] wait: bool, }, diff --git a/crates/relay-node/src/relay/cli.rs b/crates/relay-node/src/relay/cli.rs index 3045e9726..e3597876f 100644 --- a/crates/relay-node/src/relay/cli.rs +++ b/crates/relay-node/src/relay/cli.rs @@ -368,8 +368,9 @@ pub fn devin_hook(development: bool) -> Result<()> { /// window's extension host (which it records as running that /// conversation), claims that session's staged messages and prints them: /// `additional_context` after a tool call, `followup_message` when the turn -/// completes. With `--wait` (the `stop` entry) an editor conversation's -/// completed turn with nothing pending waits for its next message instead +/// completes. With `--wait` (the `stop` entry) a completed turn with +/// nothing pending, in the editor or a CLI session, waits for its next +/// message instead /// (`cursor_cli::wait`). A claim is tombstoned once the answer is written /// and put back if it is not (`cursor_cli::run_hook`). Anything else exits 0 /// with `cursor_cli::no_answer`'s output (`{}`, or `{"continue":true}` for diff --git a/crates/relay-node/src/relay/cursor_cli.rs b/crates/relay-node/src/relay/cursor_cli.rs index c5ea0131c..6ef6a4c0c 100644 --- a/crates/relay-node/src/relay/cursor_cli.rs +++ b/crates/relay-node/src/relay/cursor_cli.rs @@ -16,10 +16,10 @@ //! - **Delivery** stages a file in Agent Relay's own inbox for the session. //! Agent Relay's hooks in `~/.cursor/hooks.json` hand it to the session //! at its next tool call (`postToolUse` → `additional_context`) or when -//! its turn ends (`stop` → `followup_message`, a new user turn). A CLI -//! session idle at its prompt runs no hook, so its messages wait for its -//! person. An editor conversation's `stop` hook waits after a completed -//! turn instead, and answers the next message staged for it (`wait`). +//! its turn ends (`stop` → `followup_message`, a new user turn). A +//! conversation idle at its prompt runs no hook, so its `stop` hook waits +//! after a completed turn instead, and answers the next message staged +//! for it (`wait`). //! - **Settlement** claims a message before the hook prints it and //! tombstones it once the answer is written. A claim the hook never //! finished is settled by Cursor's transcript, which records a `stop` @@ -427,7 +427,7 @@ const HOOK_ANCESTRY: usize = 8; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HookCaller { /// The one `cursor-agent` holding the session's store. - Cli, + Cli { agent: i32 }, /// A Cursor window's extension host (`editor`), which runs the /// conversation the payload names. Editor { host: i32 }, @@ -459,7 +459,7 @@ pub fn hook_caller( if process.uid == uid && is_cursor_process(table, pid) { let holder = held(config, table, uid, session_id).is_some_and(|held| held.pid == pid); if holder { - return Some(HookCaller::Cli); + return Some(HookCaller::Cli { agent: pid }); } if !editor::is_editor_worker(table, pid) { return None; @@ -524,11 +524,10 @@ pub fn record_editor_hook( /// Then, only under the caller `hook_caller` proves, it hands the /// conversation's staged messages over through `write`, which says whether /// the answer was written in full: claims are tombstoned once it was and -/// put back if not. With `watch` (the `stop` entry's `--wait`) an editor -/// conversation's completed turn waits for its next message (`wait::wait`). -/// A Cursor CLI session answers at once, and keeps Cursor's own limit of -/// follow-ups in a row (`wait::CURSOR_LOOP_LIMIT`), which that entry lifts. -/// `true` once it answered. +/// put back if not. With `watch` (the `stop` entry's `--wait`) a completed +/// turn, in the editor or a Cursor CLI session, waits for its next message +/// (`wait::wait`); a CLI follow-up's turn is named by its `stop` +/// (`wait::followup_turn_seen`). `true` once it answered. pub fn run_hook( config: &CursorConfig, table: &dyn ProcessTable, @@ -547,26 +546,24 @@ pub fn run_hook( } return false; } - match hook_caller(config, table, pid, session) { - None => false, - Some(HookCaller::Cli) => { - let looped = payload["hook_event_name"] == "stop" - && payload["loop_count"] - .as_u64() - .is_some_and(|count| count >= wait::CURSOR_LOOP_LIMIT); - !(watch.is_some() && looped) && answer_now(config, payload, write) + let holder = match hook_caller(config, table, pid, session) { + None => return false, + Some(HookCaller::Cli { agent }) => { + wait::followup_turn_seen(config, payload); + wait::Holder::Cli { agent } } Some(HookCaller::Editor { host }) => { if !record_editor_hook(config, table, pid, host, payload) { return false; } - match watch { - Some(watch) if wait::waits_after(payload) => { - wait::wait(config, table, host, payload, watch, write) == wait::Ended::Answered - } - _ => answer_now(config, payload, write), - } + wait::Holder::Editor { host } + } + }; + match watch { + Some(watch) if wait::waits_after(payload) => { + wait::wait(config, table, holder, payload, watch, write) == wait::Ended::Answered } + _ => answer_now(config, payload, write), } } diff --git a/crates/relay-node/src/relay/cursor_cli/inbox.rs b/crates/relay-node/src/relay/cursor_cli/inbox.rs index 2309bae99..f1ac9dac8 100644 --- a/crates/relay-node/src/relay/cursor_cli/inbox.rs +++ b/crates/relay-node/src/relay/cursor_cli/inbox.rs @@ -66,7 +66,7 @@ pub(super) fn message_file(session_id: &str, message_id: &str) -> String { format!("{session_id}.{nonce}.msg") } -const STAGED: &str = "in the session's inbox; Cursor shows it at the session's next tool call or when its turn ends, or at once to a Cursor editor chat that has finished its turn"; +const STAGED: &str = "in the session's inbox; Cursor shows it at the session's next tool call or when its turn ends, or at once to a Cursor chat or CLI session that has finished its turn"; /// Queues `text` for the session in its inbox file, renamed into place once /// written and synced, so a hook never reads half a message. `Accepted` diff --git a/crates/relay-node/src/relay/cursor_cli/tests.rs b/crates/relay-node/src/relay/cursor_cli/tests.rs index 13030f768..7e5de3a5b 100644 --- a/crates/relay-node/src/relay/cursor_cli/tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/tests.rs @@ -770,56 +770,160 @@ fn a_setting_whose_lock_cannot_be_taken_is_left_as_it_is() { assert_eq!(settings.while_waiting(|| ()), None); } -#[test] -fn a_cursor_cli_session_answers_its_turn_at_once() { - let (_temp, config, table) = world(); - // hook (501) → cursor-agent (400), which holds `SESSION`. - table.insert(501, shell(400)); +/// Agent Relay's waiting `stop` hook in `pid` for `SESSION`'s turn gen-1, +/// started as its prompt's `beforeSubmitPrompt` names it. +fn cli_stop( + config: &CursorConfig, + table: &FakeProcesses, + pid: i32, + loop_count: u64, +) -> (bool, Vec) { + let prompt = json!({ + "hook_event_name": "beforeSubmitPrompt", + "conversation_id": SESSION, + "generation_id": "gen-1", + }); + assert!(!run_hook(config, table, pid, &prompt, None, &mut |_| true)); + cli_turn_stop(config, table, pid, "gen-1", loop_count) +} + +/// That hook for turn `generation`, whatever named it. +fn cli_turn_stop( + config: &CursorConfig, + table: &FakeProcesses, + pid: i32, + generation: &str, + loop_count: u64, +) -> (bool, Vec) { let never = || false; let always = || true; let claiming = |claim: &mut dyn FnMut() -> Option| claim(); let watch = wait::Watch { - pid: 501, + pid, until: std::time::Instant::now() + Duration::from_secs(30), poll: Duration::from_millis(10), stopping: &never, installed: &always, claiming: &claiming, }; - let stop = |loop_count: u64| { - json!({ - "hook_event_name": "stop", - "conversation_id": SESSION, - "status": "completed", - "generation_id": "gen-1", - "loop_count": loop_count, - }) - }; - let run = |payload: &Value| { - let mut written = Vec::new(); - let answered = run_hook(&config, &table, 501, payload, Some(&watch), &mut |output| { - written.push(output.clone()); - true - }); - (answered, written) - }; - // With nothing pending it says nothing at once, rather than waiting. - let started = std::time::Instant::now(); - assert_eq!(run(&stop(0)), (false, vec![])); - assert!(started.elapsed() < Duration::from_secs(5)); - assert!(!config + let stop = json!({ + "hook_event_name": "stop", + "conversation_id": SESSION, + "status": "completed", + "generation_id": generation, + "loop_count": loop_count, + }); + let mut written = Vec::new(); + let answered = run_hook(config, table, pid, &stop, Some(&watch), &mut |output| { + written.push(output.clone()); + true + }); + (answered, written) +} + +fn cli_waiting(config: &CursorConfig) -> bool { + config .inbox() .join("waiting") .join(format!("{SESSION}.wait")) - .exists()); - send(&config, &table, uid(), SESSION, "m-1", "ping"); - // Cursor's own limit of follow-ups in a row, which the waiting entry - // lifts, still holds for a Cursor CLI session. - assert_eq!(run(&stop(5)), (false, vec![])); + .exists() +} + +fn eventually(what: &str, check: impl Fn() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !check() { + assert!(std::time::Instant::now() < deadline, "{what}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn a_cursor_cli_session_idle_after_its_turn_is_woken_by_its_next_message() { + let (_temp, config, table) = world(); + // hook (501) → cursor-agent (400), which holds `SESSION`. + table.insert(501, shell(400)); + std::thread::scope(|scope| { + // Past Cursor's default of five follow-ups in a row: the waiting + // entry has no limit, so a busy conversation is still woken. + let hook = scope.spawn(|| cli_stop(&config, &table, 501, 5)); + eventually("the hook waits", || cli_waiting(&config)); + std::thread::sleep(Duration::from_millis(100)); + assert!(!hook.is_finished()); + let sent = std::time::Instant::now(); + assert!(matches!( + send(&config, &table, uid(), SESSION, "m-1", "ping"), + InjectOutcome::Accepted(_) + )); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": "ping"})]) + ); + assert!(sent.elapsed() < Duration::from_secs(5)); + }); + assert_eq!( + send(&config, &table, uid(), SESSION, "m-1", "ping"), + InjectOutcome::Delivered + ); + assert!(!cli_waiting(&config)); +} + +#[test] +fn the_turn_a_cursor_cli_follow_up_starts_waits_for_the_next_message() { + let (_temp, config, table) = world(); + table.insert(501, shell(400)); + table.insert(502, shell(400)); + let wake = |hook: &(dyn Fn() -> (bool, Vec) + Sync), id: &str| { + std::thread::scope(|scope| { + let hook = scope.spawn(hook); + eventually("the hook waits", || cli_waiting(&config)); + send(&config, &table, uid(), SESSION, id, id); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": id})]) + ); + }); + }; + wake(&|| cli_stop(&config, &table, 501, 0), "m-1"); + // The CLI runs no `beforeSubmitPrompt` for the follow-up's turn gen-2: + // its `stop` is the first hook to name it. + wake(&|| cli_turn_stop(&config, &table, 502, "gen-2", 1), "m-2"); + // A late `stop` of gen-1 neither takes gen-2's place nor waits. + let started = std::time::Instant::now(); assert_eq!( - run(&stop(4)), - (true, vec![json!({"followup_message": "ping"})]) + cli_turn_stop(&config, &table, 501, "gen-1", 1), + (false, vec![]) ); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +#[test] +fn a_cursor_cli_hook_stops_waiting_once_its_agent_quits() { + let (_temp, config, table) = world(); + table.insert(501, shell(400)); + std::thread::scope(|scope| { + let hook = scope.spawn(|| cli_stop(&config, &table, 501, 0)); + eventually("the hook waits", || cli_waiting(&config)); + // The `cursor-agent` quit: its hook is handed to launchd. + table.remove(400); + table.insert(501, shell(1)); + assert_eq!(hook.join().unwrap(), (false, vec![])); + }); + assert!(!cli_waiting(&config)); +} + +#[test] +fn a_cursor_cli_hook_stops_waiting_once_its_agent_runs_another_conversation() { + let (_temp, config, table) = world(); + table.insert(501, shell(400)); + std::thread::scope(|scope| { + let hook = scope.spawn(|| cli_stop(&config, &table, 501, 0)); + eventually("the hook waits", || cli_waiting(&config)); + // `/new` or `/resume`: the same process now holds another store. + let other = store(&config, OTHER); + table.insert(400, cursor_process(1, vec![other])); + assert_eq!(hook.join().unwrap(), (false, vec![])); + }); + assert!(!cli_waiting(&config)); } #[test] diff --git a/crates/relay-node/src/relay/cursor_cli/wait.rs b/crates/relay-node/src/relay/cursor_cli/wait.rs index f560250a6..12c736d1c 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait.rs @@ -1,31 +1,38 @@ -//! The Cursor editor's `stop` hook waiting at the end of a turn, so a -//! message for a conversation that has finished its turn wakes it. +//! Cursor's `stop` hook waiting at the end of a turn, so a message for a +//! conversation that has finished its turn wakes it, in the Cursor editor +//! and in an interactive Cursor CLI session alike. //! -//! An editor conversation idle in its window runs no hook until its person -//! starts a turn. So Agent Relay's `stop` entry (`cursor-hook --wait`) -//! does not answer at once when a completed turn has nothing pending. Cursor -//! has already marked the turn completed, so the chat is idle and its person -//! can type; the hook stays until a message is staged for the conversation -//! and answers it as `followup_message`, which Cursor submits as the next -//! user turn. When that turn ends Cursor runs the hook again. Cursor CLI -//! sessions run the same entry and answer at once (`super::run_hook`). +//! A conversation idle in its window or at its CLI prompt runs no hook +//! until its person starts a turn. So Agent Relay's `stop` entry +//! (`cursor-hook --wait`) does not answer at once when a completed turn has +//! nothing pending. Cursor has already marked the turn completed, so the +//! chat is idle and its person can type; the hook stays until a message is +//! staged for the conversation and answers it as `followup_message`, which +//! Cursor submits as the next user turn. When that turn ends Cursor runs +//! the hook again. `cursor-agent -p` runs no `stop` hook, so nothing waits +//! there. //! -//! Cursor drops a follow-up once a newer turn owns the chat, and nobody -//! reads the answer of a hook whose window reloaded. So a waiting hook takes -//! a message only while -//! - its parent is still the process that started it, under the window's -//! extension host, which still runs with the same pid and start time; +//! A follow-up a hook of an older turn gives once a newer turn owns the +//! chat starts a turn nobody asked for, and nobody reads the answer of a +//! hook whose window reloaded or whose `cursor-agent` quit. So a waiting +//! hook takes a message only while +//! - its parent is still the process that started it, under the +//! conversation's holder (`Holder`), which still runs with the same pid +//! and start time; //! - it is its conversation's newest waiting hook, which //! `/waiting/.wait` names, and waits after the conversation's //! current turn, which `.turn` names: `beforeSubmitPrompt` writes it -//! as every turn starts, and every run of Agent Relay's hook first retires +//! as every turn starts (in the CLI a follow-up's turn runs none, and its +//! `stop` writes it: `followup_turn_seen`), and every run of Agent Relay's +//! hook first retires //! a waiting hook when either belongs to another turn (`turn_seen`). A //! `stop` hook of an older turn run late neither claims nor waits, and //! while `.turn` is missing, corrupt or of another version, no hook //! does, until the next turn starts; -//! - the conversation's record still gives it to that host and a hook of -//! it ran within `editor::IDLE`, and this build's waiting entry is still -//! in `~/.cursor/hooks.json`. +//! - that holder still runs the conversation: the editor's record still +//! gives it to that host and a hook of it ran within `editor::IDLE`, or +//! that `cursor-agent` still holds its store open; and this build's +//! waiting entry is still in `~/.cursor/hooks.json`. //! //! It checks again, claims and answers under one lock per conversation, //! which taking over and retiring take too, so a message is claimed by one @@ -64,11 +71,6 @@ pub const POLL: Duration = Duration::from_secs(1); /// How many polls apart it reads `hooks.json` again. const SETTINGS_EVERY: u32 = 15; -/// Cursor's limit on `stop` follow-ups in a row for an entry that sets -/// none. The waiting entry sets none (`loop_limit: null`); a Cursor CLI -/// session, which answers at once, keeps this one. -pub(super) const CURSOR_LOOP_LIMIT: u64 = 5; - const WAITING_DIR: &str = "waiting"; /// The newest waiting hook of a conversation. @@ -258,6 +260,34 @@ pub fn turn_seen(config: &CursorConfig, payload: &Value) { }); } +/// Notes the Cursor CLI turn a `stop` follow-up started as the +/// conversation's current one. The CLI runs no `beforeSubmitPrompt` for +/// such a turn, so its `stop` is the first hook to name it: a follow-up's +/// `stop` (`loop_count` above 0) of a turn that is neither the current one +/// nor an earlier one is that newer turn. Only for a hook proved to run +/// under the `cursor-agent` holding the conversation: the editor names +/// every turn as it starts. +pub fn followup_turn_seen(config: &CursorConfig, payload: &Value) { + let (Some(session), Some(generation)) = (payload_session(payload), generation(payload)) else { + return; + }; + let followup = payload["loop_count"] + .as_u64() + .is_some_and(|count| count > 0); + if payload["hook_event_name"] != "stop" || !followup { + return; + } + locked(config, session, || { + let known = read_turn(config, session).is_some_and(|turn| { + turn.generation == generation + || turn.earlier.iter().any(|earlier| earlier == generation) + }); + if !known { + start_turn(config, session, Some(generation)); + } + }); +} + /// Whether `payload` is a turn a hook waits after: a `stop` whose turn /// completed, with the turn's id. pub fn waits_after(payload: &Value) -> bool { @@ -294,13 +324,14 @@ pub enum Ended { Answered, /// The wait ran out. TimedOut, - /// Its parent or the window's extension host is gone: the window - /// reloaded or closed. + /// Its parent or the conversation's holder is gone: the window + /// reloaded or closed, or the `cursor-agent` quit. Orphaned, /// A newer waiting hook or a hook of a newer turn took over. Retired, - /// The record no longer gives the conversation to that host, or no - /// hook of it ran within `editor::IDLE`. + /// The holder no longer runs the conversation: the record no longer + /// gives it to that host, or no hook of it ran within `editor::IDLE`, + /// or the `cursor-agent` no longer holds its store. Moved, /// This build's waiting entry left `hooks.json`. Removed, @@ -311,20 +342,38 @@ pub enum Ended { Failed, } +/// What runs the conversation a waiting hook waits in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Holder { + /// A Cursor window's extension host. + Editor { host: i32 }, + /// The `cursor-agent` holding the conversation's store. + Cli { agent: i32 }, +} + +impl Holder { + fn pid(self) -> i32 { + match self { + Holder::Editor { host } => host, + Holder::Cli { agent } => agent, + } + } +} + /// Answers the completed turn of the `stop` payload `payload`, whose hook -/// runs under the extension host `host`, through `write`, which says -/// whether the answer was written in full. It answers at once when -/// messages are pending, otherwise as soon as one is staged while this -/// hook may still take it (module docs). +/// runs under `holder`, through `write`, which says whether the answer was +/// written in full. It answers at once when messages are pending, +/// otherwise as soon as one is staged while this hook may still take it +/// (module docs). pub fn wait( config: &CursorConfig, table: &dyn ProcessTable, - host: i32, + holder: Holder, payload: &Value, watch: &Watch<'_>, write: &mut dyn FnMut(&Value) -> bool, ) -> Ended { - let waiting = match Waiting::new(config, table, host, payload, watch.pid) { + let waiting = match Waiting::new(config, table, holder, payload, watch.pid) { Ok(waiting) => waiting, Err(ended) => return ended, }; @@ -375,7 +424,7 @@ fn answer( }) } -/// One waiting hook: what it waits after, and the window it waits in. +/// One waiting hook: what it waits after, and the holder it waits under. struct Waiting<'a> { config: &'a CursorConfig, table: &'a dyn ProcessTable, @@ -383,6 +432,7 @@ struct Waiting<'a> { me: Waiter, /// The hook's parent when it started. parent: i32, + holder: Holder, host: i32, host_start: u64, } @@ -391,10 +441,11 @@ impl<'a> Waiting<'a> { fn new( config: &'a CursorConfig, table: &'a dyn ProcessTable, - host: i32, + holder: Holder, payload: &'a Value, pid: i32, ) -> Result, Ended> { + let host = holder.pid(); let (Some(session), Some(generation), true) = ( payload_session(payload), generation(payload), @@ -420,6 +471,7 @@ impl<'a> Waiting<'a> { generation: generation.to_string(), }, parent, + holder, host, host_start, }) @@ -464,19 +516,30 @@ impl<'a> Waiting<'a> { .or_else(|| (!self.mine() || self.stale()).then_some(Ended::Retired)) } - /// Whether its window no longer holds the conversation, and how. + /// Whether its holder no longer runs the conversation, and how. fn lost(&self) -> Option { if !self.alive() { Some(Ended::Orphaned) - } else if !editor::holds(self.config, self.session, self.host, self.host_start) { + } else if !self.holds() { Some(Ended::Moved) } else { None } } - /// Whether its parent and the window's extension host are the ones it - /// started under, the one still under the other. + fn holds(&self) -> bool { + match self.holder { + Holder::Editor { .. } => { + editor::holds(self.config, self.session, self.host, self.host_start) + } + Holder::Cli { agent } => { + super::held_session(self.config, self.table, agent).as_deref() == Some(self.session) + } + } + } + + /// Whether its parent and its holder are the ones it started under, the + /// one still under the other. fn alive(&self) -> bool { self.table .process(self.me.pid) diff --git a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs index fa5c85145..253e605cb 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs @@ -125,10 +125,17 @@ fn park( let claiming = |claim: &mut dyn FnMut() -> Option| flags.claiming(claim); let watch = watching(pid, wait, &stopping, &installed, &claiming); let mut written = Vec::new(); - let ended = super::wait(config, table, HOST_PID, payload, &watch, &mut |output| { - written.push(output.clone()); - writes - }); + let ended = super::wait( + config, + table, + Holder::Editor { host: HOST_PID }, + payload, + &watch, + &mut |output| { + written.push(output.clone()); + writes + }, + ); (ended, written) } @@ -343,7 +350,7 @@ fn a_newer_hook_answering_at_once_retires_the_older_one_first() { let ended = super::wait( &config, &table, - HOST_PID, + Holder::Editor { host: HOST_PID }, &stop("gen-1"), &watch, &mut |output| { @@ -872,7 +879,7 @@ fn what_is_pending_is_left_when_the_window_went_away_before_the_claim() { let ended = super::wait( &config, &table, - HOST_PID, + Holder::Editor { host: HOST_PID }, &stop("gen-1"), &watch, &mut |output| { From 76b581029caa0e7384383ba03bb0e85592537343 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 8 Nov 2026 19:33:35 -0800 Subject: [PATCH 2/3] fix(relay-node): let Cursor editor chats reply and wake after Cursor-started turns Replies: Cloud's hosted MCP refuses to send as any Cursor chat (Cursor's MCP calls carry no native chat id, and Desktop's Cursor token makes the call require one), yet a Cursor session that declared "mcp":"agent-relay-sessions" was handed only the hosted send tool plus a route line forbidding any other way. Cursor sessions are now always socket-only, and a Cursor editor conversation on macOS gets the probe client (Apple's curl hides CURSOR_CONVERSATION_ID, so curl replies were refused as not_a_relay_session). Wake: Cursor runs no beforeSubmitPrompt for a turn the editor starts itself (the "background task finished" turn), so .turn kept naming the turn before and that turn's waiting stop hook retired at once, leaving the idle chat unwakeable. A completed editor stop of a turn .turn does not know now names it current (untracked_turn_seen), unless the current turn was named less than 2s before the hook started (it may be the newer turn) or .turn cannot be read. This sits beside this branch's CLI follow-up rule (followup_turn_seen), whose "the editor names every turn as it starts" note it corrects, and runs before the shared Holder::Editor wait under --wait only. First turn: a new editor conversation could not be claimed until Cursor wrote its transcript, so "put yourself on the relay" failed in a chat's first turn. A tool naming its conversation under a verified extension host now claims it, as running `cursor-hook` from that tool already could. Evidence: in conversation b72f559d the 6:40 PM notification turn wrote no .turn entry (.turn held two earlier turns after four turns, where every turn running beforeSubmitPrompt would have left three), and a message sent while idle was only handed over at the next tool call; hosted send_relay_message failed with "cannot verify this chat's desktop identity" while `agent-relay-probe send` delivered. Review: adversarial pass (trust boundary unchanged: tool env claims were already possible via cursor-hook; naming only under a proven window caller and the per-conversation lock; unreadable/missing/future-dated .turn fails safe; the CLI and editor rules never both run, since one conversation has one holder; residual race needs >2s hook start delay plus a new turn) and contextual pass (keeps the CLI follow-up rule and Holder wait as they are; no docs describe the old rules; changelog keeps both PRs' entries). Validation on this branch: cargo test (relay-node, 2363 lib + all integration tests), cargo check --locked --no-default-features --tests, scripts/check-open-source.sh, cargo fmt --check, clippy clean on touched files; each new assertion fails with its fix reverted, rechecked for the merged editor call site. Co-authored-by: Cursor --- CHANGELOG.md | 3 + crates/relay-node/src/relay/cursor_cli.rs | 24 ++-- .../relay-node/src/relay/cursor_cli/editor.rs | 4 +- .../src/relay/cursor_cli/editor_tests.rs | 36 +++--- .../relay-node/src/relay/cursor_cli/wait.rs | 64 ++++++++-- .../src/relay/cursor_cli/wait_tests.rs | 118 ++++++++++++++++++ crates/relay-node/src/relay/hosted_mcp.rs | 10 +- crates/relay-node/src/relay/local.rs | 22 +--- crates/relay-node/src/relay/local/routes.rs | 10 ++ crates/relay-node/tests/relay_outbound.rs | 34 +++++ 10 files changed, 266 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e696099bf..7d9a4914d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay-node` built for its own site (`AGENT_RELAY_SITE_URL`) no longer falls back to Agent Relay's public gateways: a sign-in whose site named no valid Relaycast URL, or a CLI workspace key with no recorded server or `RELAY_BASE_URL`, is refused with a reason naming the site, and `RELAYCAST_URL` is ignored in such a build. - `agent-relay-node` lists Codex sessions and delivers messages to them promptly when Codex's thread database is large. - Relay messages now wake an idle interactive Cursor CLI (`cursor-agent`) session within seconds, as they already did for Cursor editor chats, instead of waiting for its next tool call or prompt. This keeps working after several automatic follow-ups in a row. +- Relay messages to agents in the Cursor editor and Cursor CLI say to reply through the session socket (with `agent-relay-probe` in the Cursor editor on macOS, where `curl` cannot reply), never through hosted MCP tools, which cannot send as a Cursor chat. +- A Cursor editor chat can put itself on the relay during its first turn, before Cursor has written its transcript. +- An idle Cursor editor chat is woken by Relay messages after a turn Cursor started itself, such as one reporting that a background task finished. ## [17.2.1] - 2026-11-07 diff --git a/crates/relay-node/src/relay/cursor_cli.rs b/crates/relay-node/src/relay/cursor_cli.rs index 6ef6a4c0c..d1590dcfc 100644 --- a/crates/relay-node/src/relay/cursor_cli.rs +++ b/crates/relay-node/src/relay/cursor_cli.rs @@ -526,8 +526,10 @@ pub fn record_editor_hook( /// the answer was written in full: claims are tombstoned once it was and /// put back if not. With `watch` (the `stop` entry's `--wait`) a completed /// turn, in the editor or a Cursor CLI session, waits for its next message -/// (`wait::wait`); a CLI follow-up's turn is named by its `stop` -/// (`wait::followup_turn_seen`). `true` once it answered. +/// (`wait::wait`); a turn that ran no `beforeSubmitPrompt` is named by its +/// `stop` first, a CLI follow-up's (`wait::followup_turn_seen`) or one the +/// editor started itself (`wait::untracked_turn_seen`). `true` once it +/// answered. pub fn run_hook( config: &CursorConfig, table: &dyn ProcessTable, @@ -536,6 +538,7 @@ pub fn run_hook( watch: Option<&wait::Watch<'_>>, write: &mut dyn FnMut(&Value) -> bool, ) -> bool { + let started = std::time::SystemTime::now(); wait::turn_seen(config, payload); let Some(session) = payload_session(payload) else { return false; @@ -556,6 +559,9 @@ pub fn run_hook( if !record_editor_hook(config, table, pid, host, payload) { return false; } + if watch.is_some() { + wait::untracked_turn_seen(config, payload, started); + } wait::Holder::Editor { host } } }; @@ -596,9 +602,11 @@ fn answer_now( /// The caller a tool process `pid` of the editor's agent is, once the walk /// up from it reaches the extension host `host` of this user's: the -/// conversation its `CURSOR_CONVERSATION_ID` names, when a hook of that -/// conversation already recorded it live under this host, or nobody. A -/// tool's environment alone never puts a conversation on a host. +/// conversation its `CURSOR_CONVERSATION_ID` names, claimed for this host +/// (`editor::claim`), or nobody. A tool naming its conversation vouches for +/// it as a hook it ran itself would, so a new conversation's first tool +/// call can put it on the relay before Cursor writes the transcript a hook +/// that names none needs. pub fn editor_caller( config: &CursorConfig, table: &dyn ProcessTable, @@ -608,13 +616,7 @@ pub fn editor_caller( if !platform::SUPPORTED || !editor::is_editor_host(table, host) { return None; } - let uid = crate::relay::peer::current_uid(); let session_id = editor::tool_conversation(table, pid, host)?; - let owned = editor::live(config, table, uid, &session_id) - .is_some_and(|session| session.host_pid == host); - if !owned { - return None; - } let workspace = table .cwd(pid) .map(|path| path.to_string_lossy().into_owned()); diff --git a/crates/relay-node/src/relay/cursor_cli/editor.rs b/crates/relay-node/src/relay/cursor_cli/editor.rs index 0d3760612..18c87bba4 100644 --- a/crates/relay-node/src/relay/cursor_cli/editor.rs +++ b/crates/relay-node/src/relay/cursor_cli/editor.rs @@ -133,8 +133,8 @@ pub fn is_editor_worker(table: &dyn ProcessTable, pid: i32) -> bool { /// wherever its data folder is (`super::held_anywhere`, so the caller's /// `CURSOR_DATA_DIR` cannot hide one), no record gives it to another /// window's host that still runs (however long it has been idle), and the processes between `pid` and `host` vouch for it -/// (`path_vouches`). The first hook of a conversation's window establishes -/// its record. +/// (`path_vouches`). The first hook or tool of a conversation's window to +/// claim it establishes its record. pub(super) fn may_claim( config: &CursorConfig, table: &dyn ProcessTable, diff --git a/crates/relay-node/src/relay/cursor_cli/editor_tests.rs b/crates/relay-node/src/relay/cursor_cli/editor_tests.rs index 3759d8b11..c57d96d94 100644 --- a/crates/relay-node/src/relay/cursor_cli/editor_tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/editor_tests.rs @@ -312,6 +312,9 @@ fn a_conversation_a_cursor_agent_holds_elsewhere_is_never_the_editors() { hook_caller(&config, &table, 700, OTHER), Some(HookCaller::Editor { host: HOST_PID }) ); + // Nor does a tool naming it take it. + table.insert(701, shell(HOST_PID, &[(CONVERSATION_VAR, SESSION)])); + assert_eq!(editor_caller(&config, &table, 701, HOST_PID), None); } #[test] @@ -804,25 +807,23 @@ fn a_tool_of_the_editors_agent_is_the_conversation_its_environment_names() { // probe (702) → zsh (701) → extension host table.insert(701, shell(HOST_PID, &[])); table.insert(702, shell(701, &[(CONVERSATION_VAR, SESSION)])); - // The variable alone puts no conversation on this window. - assert_eq!(editor_caller(&config, &table, 702, HOST_PID), None); + // A new conversation's first turn: Cursor has written no transcript + // yet, so a hook naming no conversation may not take it up, but its + // tool naming it puts it on this window, and its hooks are then the + // window's. + std::fs::remove_file(own_transcript(&config, SESSION)).unwrap(); + assert_eq!(hook_caller(&config, &table, HOST_PID, SESSION), None); assert!(!running(&config, &table, uid(), SESSION)); - // Once a hook of the conversation ran here, its tools are it. - record_editor_hook( - &config, - &table, - HOST_PID, - HOST_PID, - &json!({ - "hook_event_name": "postToolUse", - "conversation_id": SESSION, - }), - ); assert_eq!( editor_caller(&config, &table, 702, HOST_PID).as_deref(), Some(SESSION) ); - // A payload without folders left none; the tool's directory fills in. + assert!(running(&config, &table, uid(), SESSION)); + assert_eq!( + hook_caller(&config, &table, HOST_PID, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // No hook named the window's folders; the tool's directory fills in. assert_eq!( live(&config, &table, uid(), SESSION).and_then(|row| row.workspace), Some("/work/api".to_string()) @@ -872,6 +873,13 @@ fn a_tool_of_the_editors_agent_is_the_conversation_its_environment_names() { assert_eq!(editor_caller(&config, &table, 703, HOST_PID), None); table.insert(704, shell(HOST_PID, &[(CONVERSATION_VAR, "../etc")])); assert_eq!(editor_caller(&config, &table, 704, HOST_PID), None); + // A process along the way whose environment cannot be read may be + // hiding another conversation: its tool claims nothing. + table.insert(707, shell(HOST_PID, &[])); + table.insert(708, shell(707, &[(CONVERSATION_VAR, OTHER)])); + table.unreadable_environment.lock().unwrap().insert(707); + assert_eq!(editor_caller(&config, &table, 708, HOST_PID), None); + assert!(!running(&config, &table, uid(), OTHER)); // And never under anything but an extension host. assert_eq!(editor_caller(&config, &table, 702, 701), None); } diff --git a/crates/relay-node/src/relay/cursor_cli/wait.rs b/crates/relay-node/src/relay/cursor_cli/wait.rs index 12c736d1c..66bda38f4 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait.rs @@ -22,13 +22,13 @@ //! - it is its conversation's newest waiting hook, which //! `/waiting/.wait` names, and waits after the conversation's //! current turn, which `.turn` names: `beforeSubmitPrompt` writes it -//! as every turn starts (in the CLI a follow-up's turn runs none, and its -//! `stop` writes it: `followup_turn_seen`), and every run of Agent Relay's -//! hook first retires -//! a waiting hook when either belongs to another turn (`turn_seen`). A -//! `stop` hook of an older turn run late neither claims nor waits, and -//! while `.turn` is missing, corrupt or of another version, no hook -//! does, until the next turn starts; +//! as a turn starts (a turn that runs none is named by its `stop`: a CLI +//! follow-up's by `followup_turn_seen`, and one Cursor started itself in +//! the editor by `untracked_turn_seen`), and every run of Agent Relay's +//! hook first retires a waiting hook when either belongs to another turn +//! (`turn_seen`). A `stop` hook of an older turn run late neither claims +//! nor waits, and while `.turn` is missing, corrupt or of another +//! version, no hook does, until the next turn starts; //! - that holder still runs the conversation: the editor's record still //! gives it to that host and a hook of it ran within `editor::IDLE`, or //! that `cursor-agent` still holds its store open; and this build's @@ -155,9 +155,12 @@ fn replace(config: &CursorConfig, path: &Path, bytes: &[u8]) -> bool { /// The format of `.turn`. A file of any other version reads as no turn. const TURN_VERSION: u32 = 1; -/// The turn a conversation started last, as `.turn` holds it. Only -/// `beforeSubmitPrompt` names it: Cursor runs that as every turn starts, a -/// `stop` follow-up's included, before the turn's other hooks. +/// The turn a conversation started last, as `.turn` holds it. +/// `beforeSubmitPrompt` names it: Cursor runs that before a turn's other +/// hooks, in the editor a `stop` follow-up's turn included, but not for a +/// turn the editor starts itself, such as the one telling the agent a +/// background task finished, nor for a CLI follow-up's turn. Such a turn's +/// `stop` names it (`untracked_turn_seen`, `followup_turn_seen`). #[derive(Serialize, Deserialize)] struct CurrentTurn { version: u32, @@ -265,8 +268,9 @@ pub fn turn_seen(config: &CursorConfig, payload: &Value) { /// such a turn, so its `stop` is the first hook to name it: a follow-up's /// `stop` (`loop_count` above 0) of a turn that is neither the current one /// nor an earlier one is that newer turn. Only for a hook proved to run -/// under the `cursor-agent` holding the conversation: the editor names -/// every turn as it starts. +/// under the `cursor-agent` holding the conversation: the editor names a +/// follow-up's turn as it starts (`untracked_turn_seen` covers the turns +/// it does not). pub fn followup_turn_seen(config: &CursorConfig, payload: &Value) { let (Some(session), Some(generation)) = (payload_session(payload), generation(payload)) else { return; @@ -288,6 +292,42 @@ pub fn followup_turn_seen(config: &CursorConfig, payload: &Value) { }); } +/// How long before a `stop` hook `started` a turn named in `.turn` +/// still keeps that hook from naming its own (`untracked_turn_seen`). +const NAMED_BEFORE: Duration = Duration::from_secs(2); + +/// Notes the turn a completed `stop` payload ends as the conversation's +/// current one when `.turn` names another turn, and knows this one +/// neither as that nor as an earlier one: Cursor started it without +/// `beforeSubmitPrompt` (`CurrentTurn`), so without this its `stop` would +/// not wait and the idle chat would take no message until its person typed. +/// A turn named after the hook `started`, or within `NAMED_BEFORE` before, +/// may be newer than the one it ends, and one that cannot be read may be +/// anything: either is left as it is. Only for a hook proved to run under +/// the window holding the conversation. +pub fn untracked_turn_seen(config: &CursorConfig, payload: &Value, started: SystemTime) { + let (Some(session), Some(generation), true) = ( + payload_session(payload), + generation(payload), + waits_after(payload), + ) else { + return; + }; + locked(config, session, || { + let Some(turn) = read_turn(config, session) else { + return; + }; + let known = turn.generation == generation + || turn.earlier.iter().any(|earlier| earlier == generation); + let named_before = std::fs::metadata(turn_file(config, session)) + .and_then(|metadata| metadata.modified()) + .is_ok_and(|named| named + NAMED_BEFORE <= started); + if !known && named_before { + start_turn(config, session, Some(generation)); + } + }); +} + /// Whether `payload` is a turn a hook waits after: a `stop` whose turn /// completed, with the turn's id. pub fn waits_after(payload: &Value) -> bool { diff --git a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs index 253e605cb..0cd53fb66 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs @@ -583,6 +583,124 @@ fn an_unknown_current_turn_takes_nothing_until_the_next_turn_starts() { } } +fn age(path: &Path, seconds: u64) { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(seconds)) + .unwrap(); +} + +#[test] +fn a_turn_cursor_started_itself_waits_after_its_stop() { + // gen-1's prompt started a turn a minute ago; then Cursor started gen-2 + // itself (a background task finished), running no `beforeSubmitPrompt`. + let (_temp, config, table) = editor(); + age(&turn_file(&config, SESSION), 60); + let flags = Flags::new(); + // A hook outside the window names nothing. + table.insert(800, shell(600, &[])); + assert_eq!( + run(&config, &table, &flags, 800, &stop("gen-2"), Some(LONG)), + (false, vec![]) + ); + assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-1")); + std::thread::scope(|scope| { + let older = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("gen-1's hook waits", || parked(&config)); + let hook = scope.spawn(|| { + run( + &config, + &table, + &flags, + HOOK + 1, + &stop("gen-2"), + Some(LONG), + ) + }); + assert_eq!(older.join().unwrap(), (Ended::Retired, vec![])); + eventually("gen-2's hook waits", || { + read(&config, SESSION).is_some_and(|waiter| waiter.pid == HOOK + 1) && parked(&config) + }); + message(&config, &table, "m-1", "ping"); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": "ping"})]) + ); + }); + let turn = read_turn(&config, SESSION).unwrap(); + assert_eq!(turn.generation, "gen-2"); + assert_eq!(turn.earlier, vec!["gen-1".to_string()]); + // gen-1's stop run late is of an earlier turn: it names nothing and + // does not wait. + place(&config, "m-2", "pong"); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 2, + &stop("gen-1"), + Some(LONG) + ), + (false, vec![]) + ); + assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); + assert_eq!(staged(&config), 1); +} + +#[test] +fn a_stop_leaves_a_turn_named_since_its_hook_started_or_just_before() { + // gen-2's prompt is named while the `stop` hook of gen-x, a turn Cursor + // started itself, is still starting: gen-2 may be the newer turn. + let (_temp, config, table) = editor(); + age(&turn_file(&config, SESSION), 60); + let flags = Flags::new(); + let started = SystemTime::now(); + start(&config, "gen-2"); + untracked_turn_seen(&config, &stop("gen-x"), started); + assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); + // A hook starting just after gen-2 was named leaves it too, and neither + // waits: the message stays for gen-2's own hooks. + place(&config, "m-1", "ping"); + assert_eq!( + run(&config, &table, &flags, HOOK, &stop("gen-x"), Some(LONG)), + (false, vec![]) + ); + assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); +} + +#[test] +fn a_stop_never_replaces_a_current_turn_that_cannot_be_read() { + let later = json!({"version": TURN_VERSION + 1, "generation": "gen-1"}).to_string(); + for contents in ["{\"version\": 1, \"gen", later.as_str()] { + let (_temp, config, table) = editor(); + std::fs::write(turn_file(&config, SESSION), contents).unwrap(); + age(&turn_file(&config, SESSION), 60); + place(&config, "m-1", "ping"); + assert_eq!( + run( + &config, + &table, + &Flags::new(), + HOOK, + &stop("gen-2"), + Some(LONG) + ), + (false, vec![]), + "{contents}" + ); + assert_eq!( + std::fs::read_to_string(turn_file(&config, SESSION)).unwrap(), + contents + ); + assert_eq!(staged(&config), 1, "{contents}"); + } +} + #[test] fn a_waiting_hook_whose_current_turn_became_unknown_takes_nothing() { let (_temp, config, table) = editor(); diff --git a/crates/relay-node/src/relay/hosted_mcp.rs b/crates/relay-node/src/relay/hosted_mcp.rs index 1a70af327..9b1f86213 100644 --- a/crates/relay-node/src/relay/hosted_mcp.rs +++ b/crates/relay-node/src/relay/hosted_mcp.rs @@ -17,7 +17,7 @@ use crate::relay::daemon::Core; use crate::relay::delivery::policy::one_line; use crate::relay::outbound::shell_quote; -use crate::relay::store::Target; +use crate::relay::store::{SessionKind, Target}; use serde::Serialize; use std::path::Path; @@ -67,10 +67,12 @@ impl McpAccess { impl Core { /// What the session `target` can use: socket commands alone for a - /// Relay Connect guest, the tools when Cloud reported a hosted MCP link - /// to it, otherwise what its registration declared. + /// Relay Connect guest or a Cursor session, the tools when Cloud + /// reported a hosted MCP link to it, otherwise what its registration + /// declared. Cursor's MCP calls name no chat, so Cloud refuses to act as + /// any Cursor session's desktop identity, whatever it declared. pub fn relay_mcp_access(&self, target: &Target) -> McpAccess { - if self.connect_for(target).is_some() { + if self.connect_for(target).is_some() || target.kind == SessionKind::CursorCli { return McpAccess::SocketOnly; } let Some((agent_id, registered_at, declared)) = self.store.read(|store| { diff --git a/crates/relay-node/src/relay/local.rs b/crates/relay-node/src/relay/local.rs index 92cbbe08f..7dfdc8663 100644 --- a/crates/relay-node/src/relay/local.rs +++ b/crates/relay-node/src/relay/local.rs @@ -5904,26 +5904,14 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo config.uid = uid; let sessions = LocalSessions::new(config.clone(), world.table.clone()); let target = Target::new(SessionKind::CursorCli, conversation); - // Before a hook of the conversation ran in this window, nobody. - assert!(matches!( - sessions.caller_target(710), - Err(CallerMiss::NotASession) - )); - // Cursor is writing the conversation's transcript: it is running now. - let transcript = config - .cursor - .home - .join(".cursor/projects/api/agent-transcripts") - .join(conversation) - .join(format!("{conversation}.jsonl")); - std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); - std::fs::write(&transcript, b"").unwrap(); - assert!(cursor_cli::record_editor_hook( + // Before Cursor wrote the conversation's transcript, a hook naming + // no conversation may not take it up; its tool naming it does. + assert!(!cursor_cli::record_editor_hook( &config.cursor, world.table.as_ref(), 700, 700, - &serde_json::json!({"hook_event_name": "postToolUse", "conversation_id": conversation}), + &serde_json::json!({"hook_event_name": "beforeSubmitPrompt", "conversation_id": conversation}), )); assert_eq!(sessions.caller_target(710).ok(), Some(target.clone())); // Without the conversation's own id, an inherited Codex thread or @@ -5939,6 +5927,8 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo ServingPath::default(), ); assert!(injector.can_reply(&target)); + // Apple's curl hides the variable naming it: its replies take the probe. + assert!(injector.needs_probe_client(&target)); // A tool under the window's session-less `cursor-agent … worker // start` is walked past the worker to the host. let worker = |pid: i32, parent: i32| FakeProcess { diff --git a/crates/relay-node/src/relay/local/routes.rs b/crates/relay-node/src/relay/local/routes.rs index eb0f72b17..b7ad203cc 100644 --- a/crates/relay-node/src/relay/local/routes.rs +++ b/crates/relay-node/src/relay/local/routes.rs @@ -52,6 +52,16 @@ impl LocalInjector { | SessionKind::OpenCodeOpen | SessionKind::GrokOpen | SessionKind::Grok => true, + // So are a Cursor editor conversation's tools, by + // `CURSOR_CONVERSATION_ID`. A Cursor CLI session's are named by + // the `cursor-agent` holding it, which curl reaches as well. + SessionKind::CursorCli => cursor_cli::held( + &self.config.cursor, + &*self.table, + self.config.uid, + &target.id, + ) + .is_none(), _ => false, } } diff --git a/crates/relay-node/tests/relay_outbound.rs b/crates/relay-node/tests/relay_outbound.rs index a4fa42db1..72ae36b29 100644 --- a/crates/relay-node/tests/relay_outbound.rs +++ b/crates/relay-node/tests/relay_outbound.rs @@ -4481,6 +4481,40 @@ async fn the_mcp_a_session_declares_picks_its_reply_instructions() { h.stop().await; } +#[tokio::test] +async fn a_cursor_session_is_handed_the_socket_whatever_it_declares() { + use agent_relay_node::relay::hosted_mcp::McpAccess; + let h = Harness::start().await; + let chat = session( + SessionKind::CursorCli, + "b72f559d-7023-4a67-944f-57f923f54ba7", + "/Users/me/Projects/api", + ); + h.sessions.0.lock().unwrap().push(chat.clone()); + h.sessions.call_from(4600, chat.clone()); + // Cloud's hosted MCP acts as no Cursor chat's desktop identity, so the + // tools a Cursor session can see would send as nobody. + let declared = h + .register( + 4600, + json!({"name": "cursor-api", "mcp": "agent-relay-sessions"}), + ) + .await; + assert_eq!(declared["mcp"], "agent-relay-sessions"); + assert_eq!(h.core.relay_mcp_access(&chat.target), McpAccess::SocketOnly); + h.fake.seed_agent("alice", "agent", json!({})); + *h.injector.hint.lock().unwrap() = Some("[reply: probe send]".into()); + let (delivery, _) = h.fake.send_dm("alice", "cursor-api", "ping"); + eventually("delivered", || { + h.fake.delivery_state(&delivery).map(|d| d.state).as_deref() == Some("acked") + }) + .await; + let text = &h.injector.injected()[0].text; + assert!(text.contains("[reply: probe send]"), "{text}"); + assert!(!text.contains("send_relay_message"), "{text}"); + h.stop().await; +} + #[tokio::test] async fn a_connect_guest_only_ever_gets_socket_commands() { use agent_relay_node::relay::hosted_mcp::McpAccess; From 84d9454040d12bed710e3170dd7dc4055e6705a8 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 8 Nov 2026 21:13:27 -0800 Subject: [PATCH 3/3] fix(relay-node): order Cursor turns by their stops; keep CLI waits unshared Addresses review on #2121 and #2122. Turn order (Greptile P1 on followup_turn_seen; CodeRabbit on the 2 s mtime window, clock steps and fast background turns): a stop of a turn .turn does not know was taken as the newer turn by unknown id alone (CLI) or by file times (editor). Both rules become one, stop_seen, with no clock: every proven stop marks its turn ended in .turn, and a stop of an unknown turn names it current only once the current turn ended. Cursor starts a turn only after the one before it ended, so that turn is newer; a prompt submitted before the stop ran has not ended and is left in place. Missing, unreadable or pre-upgrade .turn files still fail closed until the next prompt. Shared CLI conversations (Devin, Greptile, CodeRabbit): a waiting CLI hook kept claiming after a second cursor-agent resumed the same conversation. It now rechecks, as hook_caller does, that its agent is the one holder before each claim, and ends Moved otherwise. Channel and Connect replies (Devin on #2122): a Cursor editor chat on macOS got curl for channel replies and subscriptions and Relay Connect replies; they now use the probe like its direct-message replies. Review: adversarial pass (naming only under a proven caller and the per-conversation lock; a prompt between turn_seen and stop_seen is not ended and is kept; a subagent's stop can no longer take a running parent turn; the extra process scan runs only at claim time; .turn stays version 1 and older files read as not ended) and contextual pass (one rule for editor and CLI replaces two; docs, changelog and PR notes updated; no stale references). Validation: cargo test (relay-node, 2367 lib + all integration tests), cargo check --locked --no-default-features --tests, scripts/check-open-source.sh, cargo fmt --check, clippy clean on touched code; nine mutations (each fix reverted) each fail their new test. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- crates/relay-node/src/relay/connect.rs | 68 ++++++-- .../src/relay/connect/reply_hint.rs | 7 +- crates/relay-node/src/relay/cursor_cli.rs | 15 +- .../relay-node/src/relay/cursor_cli/tests.rs | 71 +++++++- .../relay-node/src/relay/cursor_cli/wait.rs | 151 +++++++++--------- .../src/relay/cursor_cli/wait_tests.rs | 75 +++++---- .../relay-node/src/relay/outbound/channels.rs | 29 +++- 8 files changed, 284 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9a4914d..4d5cdcb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `agent-relay-node` built for its own site (`AGENT_RELAY_SITE_URL`) no longer falls back to Agent Relay's public gateways: a sign-in whose site named no valid Relaycast URL, or a CLI workspace key with no recorded server or `RELAY_BASE_URL`, is refused with a reason naming the site, and `RELAYCAST_URL` is ignored in such a build. - `agent-relay-node` lists Codex sessions and delivers messages to them promptly when Codex's thread database is large. - Relay messages now wake an idle interactive Cursor CLI (`cursor-agent`) session within seconds, as they already did for Cursor editor chats, instead of waiting for its next tool call or prompt. This keeps working after several automatic follow-ups in a row. -- Relay messages to agents in the Cursor editor and Cursor CLI say to reply through the session socket (with `agent-relay-probe` in the Cursor editor on macOS, where `curl` cannot reply), never through hosted MCP tools, which cannot send as a Cursor chat. +- Relay messages, channel posts and Relay Connect messages to agents in the Cursor editor and Cursor CLI say to reply through the session socket (with `agent-relay-probe` in the Cursor editor on macOS, where `curl` cannot reply), never through hosted MCP tools, which cannot send as a Cursor chat. - A Cursor editor chat can put itself on the relay during its first turn, before Cursor has written its transcript. - An idle Cursor editor chat is woken by Relay messages after a turn Cursor started itself, such as one reporting that a background task finished. diff --git a/crates/relay-node/src/relay/connect.rs b/crates/relay-node/src/relay/connect.rs index 5d4d2182e..37b08799c 100644 --- a/crates/relay-node/src/relay/connect.rs +++ b/crates/relay-node/src/relay/connect.rs @@ -1687,18 +1687,34 @@ impl Core { let probe = match needs_probe { Some(known) => known, None => { - let known = session.target.kind == SessionKind::Codex && { - let thread = session.target.id.clone(); - let processes = self.plugins.processes.clone(); - tokio::task::spawn_blocking(move || { - needs_probe_reply_client( - processes.as_ref(), - current_uid(), - &thread, - ) - }) - .await - .unwrap_or(false) + let known = match session.target.kind { + SessionKind::Codex => { + let thread = session.target.id.clone(); + let processes = self.plugins.processes.clone(); + tokio::task::spawn_blocking(move || { + needs_probe_reply_client( + processes.as_ref(), + current_uid(), + &thread, + ) + }) + .await + .unwrap_or(false) + } + // A Cursor editor conversation, as its + // other reply commands do. + SessionKind::CursorCli => { + let target = session.target.clone(); + let injector = self.plugins.injector.clone(); + tokio::task::spawn_blocking(move || { + injector + .relay_socket(&target) + .is_some_and(|(_, probe)| probe) + }) + .await + .unwrap_or(false) + } + _ => false, }; *needs_probe.insert(known) } @@ -2121,6 +2137,34 @@ mod tests { assert!(direct_cli.contains("[reply: curl -s --unix-socket")); } + // A Cursor editor conversation's tools are named by + // `CURSOR_CONVERSATION_ID`, which Apple's curl hides; a Cursor CLI + // session's by the `cursor-agent` holding it, which curl reaches too. + #[cfg(target_os = "macos")] + #[test] + fn mac_cursor_editor_reply_uses_probe() { + let editor = connect_reply_line( + Path::new("/tmp/run.sock"), + "host", + SessionKind::CursorCli, + true, + ) + .unwrap(); + assert!( + editor.contains(" connect-send --socket /tmp/run.sock --to host "), + "{editor}" + ); + assert!(!editor.contains("curl"), "{editor}"); + let cli = connect_reply_line( + Path::new("/tmp/run.sock"), + "host", + SessionKind::CursorCli, + false, + ) + .unwrap(); + assert!(cli.contains("[reply: curl -s --unix-socket"), "{cli}"); + } + #[test] fn only_debug_loopback_origins_can_override_the_cloud_join_url() { assert_eq!( diff --git a/crates/relay-node/src/relay/connect/reply_hint.rs b/crates/relay-node/src/relay/connect/reply_hint.rs index 44e7b0a53..4f39c2e38 100644 --- a/crates/relay-node/src/relay/connect/reply_hint.rs +++ b/crates/relay-node/src/relay/connect/reply_hint.rs @@ -26,12 +26,15 @@ pub(super) fn connect_reply_line( let socket = socket.to_str()?; let request_path = format!("/connect/send?to={}", query_encode(sender)); // macOS hides the environment of /usr/bin/curl from KERN_PROCARGS2. - // Use the probe executable so an app-server command keeps its thread ID. + // Use the probe executable so an app-server command keeps its thread ID, + // and a Cursor editor tool its `CURSOR_CONVERSATION_ID`. // On Windows the socket is a named pipe curl cannot reach, so every // session gets the probe client. // Claude Code's permission rules from Agent Relay allow the probe's // client (`outbound::prefers_probe_client`); curl only when it is unknown. - let required = cfg!(windows) || (kind == SessionKind::Codex && probe_client(needs_probe)); + let required = cfg!(windows) + || (matches!(kind, SessionKind::Codex | SessionKind::CursorCli) + && probe_client(needs_probe)); if required || crate::relay::outbound::prefers_probe_client(kind) { let reply = crate::relay::session_cli::SessionCommand::ConnectSend { to: Some(sender.to_string()), diff --git a/crates/relay-node/src/relay/cursor_cli.rs b/crates/relay-node/src/relay/cursor_cli.rs index d1590dcfc..2b10e5b85 100644 --- a/crates/relay-node/src/relay/cursor_cli.rs +++ b/crates/relay-node/src/relay/cursor_cli.rs @@ -526,9 +526,8 @@ pub fn record_editor_hook( /// the answer was written in full: claims are tombstoned once it was and /// put back if not. With `watch` (the `stop` entry's `--wait`) a completed /// turn, in the editor or a Cursor CLI session, waits for its next message -/// (`wait::wait`); a turn that ran no `beforeSubmitPrompt` is named by its -/// `stop` first, a CLI follow-up's (`wait::followup_turn_seen`) or one the -/// editor started itself (`wait::untracked_turn_seen`). `true` once it +/// (`wait::wait`), once its `stop` noted the turn ended, and named it when +/// it ran no `beforeSubmitPrompt` (`wait::stop_seen`). `true` once it /// answered. pub fn run_hook( config: &CursorConfig, @@ -538,7 +537,6 @@ pub fn run_hook( watch: Option<&wait::Watch<'_>>, write: &mut dyn FnMut(&Value) -> bool, ) -> bool { - let started = std::time::SystemTime::now(); wait::turn_seen(config, payload); let Some(session) = payload_session(payload) else { return false; @@ -551,20 +549,15 @@ pub fn run_hook( } let holder = match hook_caller(config, table, pid, session) { None => return false, - Some(HookCaller::Cli { agent }) => { - wait::followup_turn_seen(config, payload); - wait::Holder::Cli { agent } - } + Some(HookCaller::Cli { agent }) => wait::Holder::Cli { agent }, Some(HookCaller::Editor { host }) => { if !record_editor_hook(config, table, pid, host, payload) { return false; } - if watch.is_some() { - wait::untracked_turn_seen(config, payload, started); - } wait::Holder::Editor { host } } }; + wait::stop_seen(config, payload); match watch { Some(watch) if wait::waits_after(payload) => { wait::wait(config, table, holder, payload, watch, write) == wait::Ended::Answered diff --git a/crates/relay-node/src/relay/cursor_cli/tests.rs b/crates/relay-node/src/relay/cursor_cli/tests.rs index 7e5de3a5b..7b113fcec 100644 --- a/crates/relay-node/src/relay/cursor_cli/tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/tests.rs @@ -887,13 +887,80 @@ fn the_turn_a_cursor_cli_follow_up_starts_waits_for_the_next_message() { // The CLI runs no `beforeSubmitPrompt` for the follow-up's turn gen-2: // its `stop` is the first hook to name it. wake(&|| cli_turn_stop(&config, &table, 502, "gen-2", 1), "m-2"); - // A late `stop` of gen-1 neither takes gen-2's place nor waits. + std::thread::scope(|scope| { + let hook = scope.spawn(|| cli_turn_stop(&config, &table, 502, "gen-3", 1)); + eventually("the hook waits", || cli_waiting(&config)); + // A late `stop` of gen-2, while gen-3's hook waits, neither takes + // gen-3's place nor waits. + let started = std::time::Instant::now(); + assert_eq!( + cli_turn_stop(&config, &table, 501, "gen-2", 1), + (false, vec![]) + ); + assert!(started.elapsed() < Duration::from_secs(5)); + send(&config, &table, uid(), SESSION, "m-3", "m-3"); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": "m-3"})]) + ); + }); +} + +#[test] +fn a_follow_ups_stop_run_after_a_newer_prompt_leaves_that_prompts_turn() { + let (_temp, config, table) = world(); + for pid in 501..=503 { + table.insert(pid, shell(400)); + } + // gen-1's hook wakes the session: the follow-up starts gen-2. + std::thread::scope(|scope| { + let hook = scope.spawn(|| cli_stop(&config, &table, 501, 0)); + eventually("the hook waits", || cli_waiting(&config)); + send(&config, &table, uid(), SESSION, "m-1", "m-1"); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": "m-1"})]) + ); + }); + // Its person submits gen-3 before gen-2's `stop` hook runs. + let prompt = json!({ + "hook_event_name": "beforeSubmitPrompt", + "conversation_id": SESSION, + "generation_id": "gen-3", + }); + assert!(!run_hook(&config, &table, 503, &prompt, None, &mut |_| { + true + })); + send(&config, &table, uid(), SESSION, "m-2", "m-2"); + // gen-2's `stop` then neither takes gen-3's place nor waits. let started = std::time::Instant::now(); assert_eq!( - cli_turn_stop(&config, &table, 501, "gen-1", 1), + cli_turn_stop(&config, &table, 502, "gen-2", 1), (false, vec![]) ); assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(pending(&config, SESSION).len(), 1); + // gen-3's own `stop` takes the message. + assert_eq!( + cli_turn_stop(&config, &table, 503, "gen-3", 0), + (true, vec![json!({"followup_message": "m-2"})]) + ); +} + +#[test] +fn a_cursor_cli_hook_takes_nothing_once_a_second_agent_resumes_its_conversation() { + let (_temp, config, table) = world(); + table.insert(501, shell(400)); + std::thread::scope(|scope| { + let hook = scope.spawn(|| cli_stop(&config, &table, 501, 0)); + eventually("the hook waits", || cli_waiting(&config)); + // `cursor-agent --resume` in another terminal holds the same store. + table.insert(401, cursor_process(1, vec![store(&config, SESSION)])); + place(&config, &format!("{SESSION}.m-1.msg"), "ping"); + assert_eq!(hook.join().unwrap(), (false, vec![])); + }); + assert!(!cli_waiting(&config)); + assert_eq!(pending(&config, SESSION).len(), 1); } #[test] diff --git a/crates/relay-node/src/relay/cursor_cli/wait.rs b/crates/relay-node/src/relay/cursor_cli/wait.rs index 66bda38f4..1c452cf73 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait.rs @@ -22,17 +22,17 @@ //! - it is its conversation's newest waiting hook, which //! `/waiting/.wait` names, and waits after the conversation's //! current turn, which `.turn` names: `beforeSubmitPrompt` writes it -//! as a turn starts (a turn that runs none is named by its `stop`: a CLI -//! follow-up's by `followup_turn_seen`, and one Cursor started itself in -//! the editor by `untracked_turn_seen`), and every run of Agent Relay's -//! hook first retires a waiting hook when either belongs to another turn -//! (`turn_seen`). A `stop` hook of an older turn run late neither claims -//! nor waits, and while `.turn` is missing, corrupt or of another -//! version, no hook does, until the next turn starts; +//! as a turn starts (a turn that runs none, a CLI follow-up's or one the +//! editor started itself, is named by its `stop` once the turn before it +//! ended: `stop_seen`), and every run of Agent Relay's hook first retires +//! a waiting hook when either belongs to another turn (`turn_seen`). A +//! `stop` hook of an older turn run late neither claims nor waits, and +//! while `.turn` is missing, corrupt or of another version, no hook +//! does, until the next turn starts; //! - that holder still runs the conversation: the editor's record still //! gives it to that host and a hook of it ran within `editor::IDLE`, or -//! that `cursor-agent` still holds its store open; and this build's -//! waiting entry is still in `~/.cursor/hooks.json`. +//! that `cursor-agent` is still the one process holding its store open; +//! and this build's waiting entry is still in `~/.cursor/hooks.json`. //! //! It checks again, claims and answers under one lock per conversation, //! which taking over and retiring take too, so a message is claimed by one @@ -160,11 +160,16 @@ const TURN_VERSION: u32 = 1; /// hooks, in the editor a `stop` follow-up's turn included, but not for a /// turn the editor starts itself, such as the one telling the agent a /// background task finished, nor for a CLI follow-up's turn. Such a turn's -/// `stop` names it (`untracked_turn_seen`, `followup_turn_seen`). +/// `stop` names it (`stop_seen`). #[derive(Serialize, Deserialize)] struct CurrentTurn { version: u32, generation: String, + /// Whether its `stop` ran. Cursor starts a turn only once the one + /// before it ended, so a turn not named yet whose `stop` runs after + /// this is newer (`stop_seen`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ended: bool, /// The turns before it, oldest first (`EARLIER_TURNS`): a hook of one /// is late, where a hook of an id not here may be a newer turn's. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -191,11 +196,20 @@ fn read_turn(config: &CursorConfig, session: &str) -> Option { (turn.version == TURN_VERSION).then_some(turn) } -/// Names `generation`, when there is one, the turn that just started. -/// When it cannot be written the current turn is forgotten instead, so an -/// older one is never taken for the newest. -fn start_turn(config: &CursorConfig, session: &str, generation: Option<&str>) -> Option { - let path = turn_file(config, session); +fn write_turn(config: &CursorConfig, session: &str, turn: &CurrentTurn) -> bool { + serde_json::to_vec(turn).is_ok_and(|bytes| replace(config, &turn_file(config, session), &bytes)) +} + +/// Names `generation`, when there is one, the turn that just started, or +/// with `ended` the one whose `stop` just ran. When it cannot be written +/// the current turn is forgotten instead, so an older one is never taken +/// for the newest. +fn start_turn( + config: &CursorConfig, + session: &str, + generation: Option<&str>, + ended: bool, +) -> Option { let mut earlier = Vec::new(); if let Some(turn) = read_turn(config, session) { earlier = turn.earlier; @@ -205,15 +219,16 @@ fn start_turn(config: &CursorConfig, session: &str, generation: Option<&str>) -> earlier.drain(..earlier.len().saturating_sub(EARLIER_TURNS)); } let written = generation.filter(|generation| { - serde_json::to_vec(&CurrentTurn { + let turn = CurrentTurn { version: TURN_VERSION, generation: generation.to_string(), + ended, earlier: earlier.clone(), - }) - .is_ok_and(|bytes| replace(config, &path, &bytes)) + }; + write_turn(config, session, &turn) }); if written.is_none() { - let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(turn_file(config, session)); } written.map(str::to_string) } @@ -245,7 +260,7 @@ pub fn turn_seen(config: &CursorConfig, payload: &Value) { let generation = generation(payload); locked(config, session, || { let current = if starts { - start_turn(config, session, generation) + start_turn(config, session, generation, false) } else { current_turn(config, session) }; @@ -263,67 +278,40 @@ pub fn turn_seen(config: &CursorConfig, payload: &Value) { }); } -/// Notes the Cursor CLI turn a `stop` follow-up started as the -/// conversation's current one. The CLI runs no `beforeSubmitPrompt` for -/// such a turn, so its `stop` is the first hook to name it: a follow-up's -/// `stop` (`loop_count` above 0) of a turn that is neither the current one -/// nor an earlier one is that newer turn. Only for a hook proved to run -/// under the `cursor-agent` holding the conversation: the editor names a -/// follow-up's turn as it starts (`untracked_turn_seen` covers the turns -/// it does not). -pub fn followup_turn_seen(config: &CursorConfig, payload: &Value) { +/// Notes that the turn whose `stop` `payload` is has ended (`CurrentTurn`). +/// A turn `.turn` knows neither as the current one nor as an earlier +/// one ran no `beforeSubmitPrompt`: a CLI follow-up's, or one the editor +/// started itself. It becomes the current turn once the current one has +/// ended, as Cursor started it after that; without this its `stop` would +/// not wait and the idle chat would take no message until its person +/// typed. A current turn that has not ended may be newer than the one this +/// `stop` ends (its prompt submitted before this hook ran), and one that +/// cannot be read may be anything: either is left as it is. Only for a +/// hook proved to run under the conversation's holder. +pub fn stop_seen(config: &CursorConfig, payload: &Value) { let (Some(session), Some(generation)) = (payload_session(payload), generation(payload)) else { return; }; - let followup = payload["loop_count"] - .as_u64() - .is_some_and(|count| count > 0); - if payload["hook_event_name"] != "stop" || !followup { + if payload["hook_event_name"] != "stop" { return; } - locked(config, session, || { - let known = read_turn(config, session).is_some_and(|turn| { - turn.generation == generation - || turn.earlier.iter().any(|earlier| earlier == generation) - }); - if !known { - start_turn(config, session, Some(generation)); - } - }); -} - -/// How long before a `stop` hook `started` a turn named in `.turn` -/// still keeps that hook from naming its own (`untracked_turn_seen`). -const NAMED_BEFORE: Duration = Duration::from_secs(2); - -/// Notes the turn a completed `stop` payload ends as the conversation's -/// current one when `.turn` names another turn, and knows this one -/// neither as that nor as an earlier one: Cursor started it without -/// `beforeSubmitPrompt` (`CurrentTurn`), so without this its `stop` would -/// not wait and the idle chat would take no message until its person typed. -/// A turn named after the hook `started`, or within `NAMED_BEFORE` before, -/// may be newer than the one it ends, and one that cannot be read may be -/// anything: either is left as it is. Only for a hook proved to run under -/// the window holding the conversation. -pub fn untracked_turn_seen(config: &CursorConfig, payload: &Value, started: SystemTime) { - let (Some(session), Some(generation), true) = ( - payload_session(payload), - generation(payload), - waits_after(payload), - ) else { - return; - }; locked(config, session, || { let Some(turn) = read_turn(config, session) else { return; }; - let known = turn.generation == generation - || turn.earlier.iter().any(|earlier| earlier == generation); - let named_before = std::fs::metadata(turn_file(config, session)) - .and_then(|metadata| metadata.modified()) - .is_ok_and(|named| named + NAMED_BEFORE <= started); - if !known && named_before { - start_turn(config, session, Some(generation)); + if turn.generation == generation { + if !turn.ended { + let _ = write_turn( + config, + session, + &CurrentTurn { + ended: true, + ..turn + }, + ); + } + } else if turn.ended && !turn.earlier.iter().any(|earlier| earlier == generation) { + start_turn(config, session, Some(generation), true); } }); } @@ -371,7 +359,8 @@ pub enum Ended { Retired, /// The holder no longer runs the conversation: the record no longer /// gives it to that host, or no hook of it ran within `editor::IDLE`, - /// or the `cursor-agent` no longer holds its store. + /// or the `cursor-agent` no longer holds its store or another one holds + /// it too. Moved, /// This build's waiting entry left `hooks.json`. Removed, @@ -424,6 +413,7 @@ pub fn wait( let first = locked(config, waiting.session, || { interrupted(watch) .or_else(|| waiting.lost()) + .or_else(|| waiting.shared()) .or_else(|| waiting.stale().then_some(Ended::Retired)) .or_else(|| { (watch.claiming)(&mut || { @@ -537,6 +527,7 @@ impl<'a> Waiting<'a> { if !pending(self.config, self.session).is_empty() { let taken = locked(self.config, self.session, || { self.gone() + .or_else(|| self.shared()) .or_else(|| (watch.claiming)(&mut || answer(self.config, payload, write))) }); match taken { @@ -578,6 +569,20 @@ impl<'a> Waiting<'a> { } } + /// Whether a second `cursor-agent` resumed its CLI conversation beside + /// its own, which leaves whose turn a hook ends unknowable, so a hook + /// starting now takes nothing either (`super::hook_caller`). Checked only + /// as it claims, since it reads every process. + fn shared(&self) -> Option { + let Holder::Cli { agent } = self.holder else { + return None; + }; + let uid = crate::relay::peer::current_uid(); + let alone = super::held(self.config, self.table, uid, self.session) + .is_some_and(|held| held.pid == agent); + (!alone).then_some(Ended::Moved) + } + /// Whether its parent and its holder are the ones it started under, the /// one still under the other. fn alive(&self) -> bool { diff --git a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs index 0cd53fb66..6057bcde0 100644 --- a/crates/relay-node/src/relay/cursor_cli/wait_tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/wait_tests.rs @@ -583,32 +583,17 @@ fn an_unknown_current_turn_takes_nothing_until_the_next_turn_starts() { } } -fn age(path: &Path, seconds: u64) { - std::fs::File::options() - .write(true) - .open(path) - .unwrap() - .set_modified(SystemTime::now() - Duration::from_secs(seconds)) - .unwrap(); -} - #[test] fn a_turn_cursor_started_itself_waits_after_its_stop() { - // gen-1's prompt started a turn a minute ago; then Cursor started gen-2 - // itself (a background task finished), running no `beforeSubmitPrompt`. + // gen-1's prompt started a turn just now, and it ended; then Cursor + // started gen-2 itself (a background task finished), running no + // `beforeSubmitPrompt`. However soon it ends, its `stop` names it. let (_temp, config, table) = editor(); - age(&turn_file(&config, SESSION), 60); let flags = Flags::new(); - // A hook outside the window names nothing. - table.insert(800, shell(600, &[])); - assert_eq!( - run(&config, &table, &flags, 800, &stop("gen-2"), Some(LONG)), - (false, vec![]) - ); - assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-1")); std::thread::scope(|scope| { - let older = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + let older = scope.spawn(|| run(&config, &table, &flags, HOOK, &stop("gen-1"), Some(LONG))); eventually("gen-1's hook waits", || parked(&config)); + assert!(read_turn(&config, SESSION).unwrap().ended); let hook = scope.spawn(|| { run( &config, @@ -619,7 +604,7 @@ fn a_turn_cursor_started_itself_waits_after_its_stop() { Some(LONG), ) }); - assert_eq!(older.join().unwrap(), (Ended::Retired, vec![])); + assert_eq!(older.join().unwrap(), (false, vec![])); eventually("gen-2's hook waits", || { read(&config, SESSION).is_some_and(|waiter| waiter.pid == HOOK + 1) && parked(&config) }); @@ -632,6 +617,7 @@ fn a_turn_cursor_started_itself_waits_after_its_stop() { let turn = read_turn(&config, SESSION).unwrap(); assert_eq!(turn.generation, "gen-2"); assert_eq!(turn.earlier, vec!["gen-1".to_string()]); + assert!(turn.ended); // gen-1's stop run late is of an earlier turn: it names nothing and // does not wait. place(&config, "m-2", "pong"); @@ -648,29 +634,55 @@ fn a_turn_cursor_started_itself_waits_after_its_stop() { ); assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); assert_eq!(staged(&config), 1); + // A hook outside the window names nothing, though gen-2 ended. + table.insert(800, shell(600, &[])); + assert_eq!( + run(&config, &table, &flags, 800, &stop("gen-3"), Some(LONG)), + (false, vec![]) + ); + assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); } #[test] -fn a_stop_leaves_a_turn_named_since_its_hook_started_or_just_before() { - // gen-2's prompt is named while the `stop` hook of gen-x, a turn Cursor - // started itself, is still starting: gen-2 may be the newer turn. +fn a_stop_leaves_a_current_turn_that_has_not_ended() { + // gen-1 ended, then gen-2's prompt is named before the `stop` hook of + // gen-x, a turn Cursor started itself before gen-2, runs. let (_temp, config, table) = editor(); - age(&turn_file(&config, SESSION), 60); let flags = Flags::new(); - let started = SystemTime::now(); + assert_eq!( + run(&config, &table, &flags, HOOK, &stop("gen-1"), None), + (false, vec![]) + ); start(&config, "gen-2"); - untracked_turn_seen(&config, &stop("gen-x"), started); - assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); - // A hook starting just after gen-2 was named leaves it too, and neither - // waits: the message stays for gen-2's own hooks. place(&config, "m-1", "ping"); + let started = Instant::now(); assert_eq!( - run(&config, &table, &flags, HOOK, &stop("gen-x"), Some(LONG)), + run( + &config, + &table, + &flags, + HOOK + 1, + &stop("gen-x"), + Some(LONG) + ), (false, vec![]) ); + assert!(started.elapsed() < Duration::from_secs(5)); assert_eq!(current_turn(&config, SESSION).as_deref(), Some("gen-2")); assert_eq!(staged(&config), 1); assert!(!waiting(&config)); + // gen-2's own `stop` takes the message. + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 2, + &stop("gen-2"), + Some(LONG) + ), + (true, vec![json!({"followup_message": "ping"})]) + ); } #[test] @@ -679,7 +691,6 @@ fn a_stop_never_replaces_a_current_turn_that_cannot_be_read() { for contents in ["{\"version\": 1, \"gen", later.as_str()] { let (_temp, config, table) = editor(); std::fs::write(turn_file(&config, SESSION), contents).unwrap(); - age(&turn_file(&config, SESSION), 60); place(&config, "m-1", "ping"); assert_eq!( run( diff --git a/crates/relay-node/src/relay/outbound/channels.rs b/crates/relay-node/src/relay/outbound/channels.rs index 85e4f9f52..a44ceed5c 100644 --- a/crates/relay-node/src/relay/outbound/channels.rs +++ b/crates/relay-node/src/relay/outbound/channels.rs @@ -325,7 +325,10 @@ pub fn socket_command( || (cfg!(target_os = "macos") && matches!( kind, - SessionKind::Codex | SessionKind::OpenCodeOpen | SessionKind::GrokOpen + SessionKind::Codex + | SessionKind::OpenCodeOpen + | SessionKind::GrokOpen + | SessionKind::CursorCli ) && needs_probe); // Claude Code's permission rules from Agent Relay allow the probe's @@ -528,4 +531,28 @@ mod tests { 'http://relay/channels/subscribe?channel=a%20b'" ); } + + // A Cursor editor conversation's tools are named by + // `CURSOR_CONVERSATION_ID`, which Apple's curl hides; a Cursor CLI + // session's by the `cursor-agent` holding it, which curl reaches too. + #[cfg(target_os = "macos")] + #[test] + fn mac_cursor_editor_channel_commands_use_the_probe_client() { + let socket = PathBuf::from("/tmp/relay.sock"); + let reply = + channel_reply_command(&socket, "swarm", "123", SessionKind::CursorCli, true).unwrap(); + assert!(reply.contains(" send --socket /tmp/relay.sock "), "{reply}"); + assert!(reply.contains("swarm") && reply.contains("123"), "{reply}"); + assert!(!reply.contains("curl"), "{reply}"); + for join in [true, false] { + let command = + channel_subscription_command(&socket, "swarm", join, SessionKind::CursorCli, true) + .unwrap(); + assert!(command.contains(" subscribe-channel "), "{command}"); + assert!(!command.contains("curl"), "{command}"); + } + let cli = + channel_reply_command(&socket, "swarm", "123", SessionKind::CursorCli, false).unwrap(); + assert!(cli.starts_with("curl "), "{cli}"); + } }