diff --git a/CHANGELOG.md b/CHANGELOG.md index 1104c6c56..4d5cdcb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ 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. +- 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. ## [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/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 c5ea0131c..2b10e5b85 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,11 @@ 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`), 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, table: &dyn ProcessTable, @@ -547,26 +547,22 @@ 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::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 } + } + }; + 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 } + _ => answer_now(config, payload, write), } } @@ -599,9 +595,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, @@ -611,13 +609,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/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..7b113fcec 100644 --- a/crates/relay-node/src/relay/cursor_cli/tests.rs +++ b/crates/relay-node/src/relay/cursor_cli/tests.rs @@ -770,58 +770,229 @@ 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 + 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() +} + +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})]) + ); }); - (answered, written) }; - // With nothing pending it says nothing at once, rather than waiting. + 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"); + 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!(run(&stop(0)), (false, vec![])); + assert_eq!( + cli_turn_stop(&config, &table, 502, "gen-2", 1), + (false, vec![]) + ); assert!(started.elapsed() < Duration::from_secs(5)); - assert!(!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![])); + assert_eq!(pending(&config, SESSION).len(), 1); + // gen-3's own `stop` takes the message. assert_eq!( - run(&stop(4)), - (true, vec![json!({"followup_message": "ping"})]) + 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] +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] fn the_setting_follows_a_symlinked_hooks_file() { let (temp, config, _table) = world(); diff --git a/crates/relay-node/src/relay/cursor_cli/wait.rs b/crates/relay-node/src/relay/cursor_cli/wait.rs index f560250a6..1c452cf73 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 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; -//! - 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` 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 @@ -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. @@ -153,13 +155,21 @@ 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 (`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")] @@ -186,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; @@ -200,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) } @@ -240,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) }; @@ -258,6 +278,44 @@ pub fn 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; + }; + if payload["hook_event_name"] != "stop" { + return; + } + locked(config, session, || { + let Some(turn) = read_turn(config, session) else { + return; + }; + 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); + } + }); +} + /// 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 +352,15 @@ 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 or another one holds + /// it too. Moved, /// This build's waiting entry left `hooks.json`. Removed, @@ -311,20 +371,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, }; @@ -335,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 || { @@ -375,7 +454,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 +462,7 @@ struct Waiting<'a> { me: Waiter, /// The hook's parent when it started. parent: i32, + holder: Holder, host: i32, host_start: u64, } @@ -391,10 +471,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 +501,7 @@ impl<'a> Waiting<'a> { generation: generation.to_string(), }, parent, + holder, host, host_start, }) @@ -445,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 { @@ -464,19 +547,44 @@ 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 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 { 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..6057bcde0 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| { @@ -576,6 +583,135 @@ fn an_unknown_current_turn_takes_nothing_until_the_next_turn_starts() { } } +#[test] +fn a_turn_cursor_started_itself_waits_after_its_stop() { + // 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(); + let flags = Flags::new(); + std::thread::scope(|scope| { + 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, + &table, + &flags, + HOOK + 1, + &stop("gen-2"), + Some(LONG), + ) + }); + 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) + }); + 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()]); + 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"); + 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); + // 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_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(); + let flags = Flags::new(); + assert_eq!( + run(&config, &table, &flags, HOOK, &stop("gen-1"), None), + (false, vec![]) + ); + start(&config, "gen-2"); + place(&config, "m-1", "ping"); + let started = Instant::now(); + assert_eq!( + 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] +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(); + 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(); @@ -872,7 +1008,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| { 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/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}"); + } } 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;