From 73c435979fe8e1b6bb663b102ba446fd4ffc82e1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 11:23:40 -0800 Subject: [PATCH 1/6] feat(probe): deliver Relay messages to agents in the Cursor editor Ports relay-desktop 1b2856ce and 6d8ab4fc's editor route: editor sessions register and receive through the existing Cursor hooks, and the stop hook can wait to wake an idle agent. Signing checks move to process/macos_signing.rs so Cursor can share them. Cursor Cloud sync and its relayhistory bump are left for a separate change. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- crates/probe/src/probe/relay_commands.rs | 13 +- crates/probe/src/relay/cli.rs | 101 +- crates/probe/src/relay/cursor_cli.rs | 277 +++++- crates/probe/src/relay/cursor_cli/editor.rs | 609 ++++++++++++ .../src/relay/cursor_cli/editor_tests.rs | 842 +++++++++++++++++ crates/probe/src/relay/cursor_cli/inbox.rs | 64 +- crates/probe/src/relay/cursor_cli/settings.rs | 158 +++- crates/probe/src/relay/cursor_cli/tests.rs | 205 ++++- crates/probe/src/relay/cursor_cli/wait.rs | 545 +++++++++++ .../probe/src/relay/cursor_cli/wait_tests.rs | 865 ++++++++++++++++++ crates/probe/src/relay/hook_agents.rs | 1 + crates/probe/src/relay/local.rs | 144 ++- crates/probe/src/relay/local/lock_held.rs | 32 +- crates/probe/src/relay/local/nearest.rs | 15 +- crates/probe/src/relay/local/plugins.rs | 9 +- crates/probe/src/relay/process.rs | 87 +- crates/probe/src/relay/process/linux.rs | 93 ++ .../probe/src/relay/process/macos_signing.rs | 107 +++ 19 files changed, 3994 insertions(+), 179 deletions(-) create mode 100644 crates/probe/src/relay/cursor_cli/editor.rs create mode 100644 crates/probe/src/relay/cursor_cli/editor_tests.rs create mode 100644 crates/probe/src/relay/cursor_cli/wait.rs create mode 100644 crates/probe/src/relay/cursor_cli/wait_tests.rs create mode 100644 crates/probe/src/relay/process/macos_signing.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e99e4cc2c..03da732d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Minor] + +### Added + +- The core delivers Relay messages to agents in the Cursor editor, not only Cursor CLI, and wakes an idle agent when a message arrives. It uses the Cursor hooks the `cursor_direct_delivery` setting installs, and refreshes installed hooks when it starts. ## [15.0.2] - 2026-11-01 diff --git a/crates/probe/src/probe/relay_commands.rs b/crates/probe/src/probe/relay_commands.rs index cd057d4e4d..2e0525cd4e 100644 --- a/crates/probe/src/probe/relay_commands.rs +++ b/crates/probe/src/probe/relay_commands.rs @@ -208,13 +208,18 @@ pub enum RelayCommands { #[arg(long)] dev: bool, }, - /// Agent Relay's Cursor CLI hook, which Cursor runs after each tool call - /// and when a turn ends: hands the session the relay messages waiting - /// for it (`additional_context`, or `followup_message` at the end). + /// Agent Relay's Cursor CLI hook, which Cursor runs after each tool call, + /// when a turn ends and before a prompt starts one: hands the session + /// the relay messages waiting for it (`additional_context`, or + /// `followup_message` at the end). CursorHook { /// 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. + #[arg(long)] + wait: bool, }, /// Agent Relay's Claude Code SessionStart hook. When this session is not /// on the relay, tells Claude to ask the person whether to put it on the @@ -412,7 +417,7 @@ pub fn run(command: RelayCommands) -> Result<()> { relay::cli::codex_stop_hook(socket.as_deref(), dev) } RelayCommands::DevinHook { dev } => relay::cli::devin_hook(dev), - RelayCommands::CursorHook { dev } => relay::cli::cursor_hook(dev), + RelayCommands::CursorHook { dev, wait } => relay::cli::cursor_hook(dev, wait), RelayCommands::ClaudeSessionHook { dev, socket } => { relay::cli::claude_session_hook(socket.as_deref(), dev) } diff --git a/crates/probe/src/relay/cli.rs b/crates/probe/src/relay/cli.rs index 17e2e9c4ac..d3f7e3f704 100644 --- a/crates/probe/src/relay/cli.rs +++ b/crates/probe/src/relay/cli.rs @@ -421,52 +421,77 @@ pub fn devin_hook(development: bool) -> Result<()> { } /// Agent Relay's Cursor CLI hook (`cursor_cli::CursorSettings` installs it -/// for `postToolUse` and `stop`). Reads Cursor's hook payload and, only -/// when this process runs under the `cursor-agent` holding the payload's -/// conversation, claims that session's staged messages and prints them: +/// for `postToolUse`, `stop` and `beforeSubmitPrompt`). Reads Cursor's hook +/// payload and, only when this process runs under the `cursor-agent` +/// holding the payload's conversation or directly under a Cursor editor +/// 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. A claim is tombstoned once the answer is written and put back -/// if it is not. Anything else prints `{}` and exits 0, so a hook can never -/// break the session it runs in. `--dev` selects Agent Relay Dev's inbox. -pub fn cursor_hook(development: bool) -> Result<()> { +/// completes. With `--wait` (the `stop` entry) an editor conversation's +/// completed turn with nothing pending 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 prints +/// `{}` and exits 0, so a hook can never break the session it runs in. +/// `--dev` selects Agent Relay Dev's inbox. +pub fn cursor_hook(development: bool, wait: bool) -> Result<()> { + use crate::relay::cursor_cli::{wait as waiting, CursorConfig, CursorSettings}; let mut input = Vec::new(); let _ = std::io::stdin().take(4 << 20).read_to_end(&mut input); let payload = serde_json::from_slice::(&input).unwrap_or_else(|_| json!({})); - let answer = paths::home().ok().and_then(|home| { + let mut stdout = std::io::stdout(); + let mut answered = false; + if let Ok(home) = paths::home() { let config = - crate::relay::cursor_cli::CursorConfig::from_env(&|key| std::env::var(key).ok(), &home) - .development(development); - let session = crate::relay::cursor_cli::payload_session(&payload)?; + CursorConfig::from_env(&|key| std::env::var(key).ok(), &home).development(development); let table = crate::relay::process::SystemProcesses::default(); - #[cfg(unix)] - let parent = std::os::unix::process::parent_id() as i32; - #[cfg(not(unix))] - let parent = { - use crate::relay::process::ProcessTable; - table - .process(std::process::id() as i32) - .map_or(0, |process| process.parent) + // From this process, so its own environment is checked too. + let pid = std::process::id() as i32; + let settings = CursorSettings::new( + &config, + CursorSettings::command_for(&std::env::current_exe().unwrap_or_default(), development), + ); + let interrupts = wait.then(waiting::Interrupts::install); + let stopping = || { + interrupts + .as_ref() + .is_some_and(waiting::Interrupts::requested) }; - if !crate::relay::cursor_cli::hook_caller_holds(&config, &table, parent, session) { - return None; - } - crate::relay::cursor_cli::hook_answer(&config, &payload).map(|answer| (config, answer)) - }); - let mut stdout = std::io::stdout(); - let Some((config, answer)) = answer else { - stdout.write_all(b"{}\n")?; - return Ok(()); - }; - let written = serde_json::to_vec(&answer.output) - .map_err(std::io::Error::other) - .and_then(|bytes| { - stdout.write_all(&bytes)?; - stdout.write_all(b"\n")?; - stdout.flush() + let installed = || settings.waits(); + let claiming = |claim: &mut dyn FnMut() -> Option| { + settings + .while_waiting(claim) + .unwrap_or(Some(waiting::Ended::Removed)) + }; + let watch = wait.then(|| waiting::Watch { + pid, + until: std::time::Instant::now() + waiting::WAIT, + poll: waiting::POLL, + stopping: &stopping, + installed: &installed, + claiming: &claiming, }); - match written { - Ok(()) => crate::relay::cursor_cli::drain(&answer.claimed), - Err(_) => crate::relay::cursor_cli::unclaim(&config, &answer.claimed), + let mut write = |output: &Value| { + answered = true; + serde_json::to_vec(output) + .map_err(std::io::Error::other) + .and_then(|bytes| { + stdout.write_all(&bytes)?; + stdout.write_all(b"\n")?; + stdout.flush() + }) + .is_ok() + }; + crate::relay::cursor_cli::run_hook( + &config, + &table, + pid, + &payload, + watch.as_ref(), + &mut write, + ); + } + if !answered { + stdout.write_all(b"{}\n")?; } Ok(()) } diff --git a/crates/probe/src/relay/cursor_cli.rs b/crates/probe/src/relay/cursor_cli.rs index a9091de655..7f6861a860 100644 --- a/crates/probe/src/relay/cursor_cli.rs +++ b/crates/probe/src/relay/cursor_cli.rs @@ -1,5 +1,7 @@ //! Cursor CLI (`cursor-agent`) sessions on this computer and the route into -//! them. Not Cursor Cloud agents, which are reached through Cursor's API. +//! them, which the Cursor editor's agent conversations share (`editor`). +//! Not Cursor Cloud agents, which Agent Relay Cloud reaches through Cursor's +//! API. //! //! A `cursor-agent` process holds its conversation's //! `/chats///store.db` open @@ -14,8 +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 session -//! idle at its prompt runs no hook, so its messages wait for its person. +//! 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`). //! - **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` @@ -99,10 +103,11 @@ impl CursorConfig { .join(name) } - /// Cursor CLI has run on this computer: it writes `cli-config.json`, - /// which the Cursor editor does not. + /// Cursor CLI has run on this computer (it writes `cli-config.json`), + /// or the Cursor editor's agent has (it keeps transcripts in + /// `projects/`). pub fn installed(&self) -> bool { - self.config_dir.join("cli-config.json").is_file() + self.config_dir.join("cli-config.json").is_file() || self.data_dir.join("projects").is_dir() } } @@ -257,6 +262,24 @@ pub fn held( .find(|session| session.session_id == session_id) } +/// Whether any live `cursor-agent` of `uid`'s holds a `/store.db` +/// open, under whichever data folder: what a hook's or tool's own +/// `CURSOR_DATA_DIR` names cannot hide a CLI session from the editor route. +pub(crate) fn held_anywhere(table: &dyn ProcessTable, uid: u32, session_id: &str) -> bool { + table.all_pids().into_iter().any(|pid| { + table.process(pid).is_some_and(|process| process.uid == uid) + && !table.zombie(pid) + && is_cursor_process(table, pid) + && table.open_files(pid).iter().any(|path| { + path.file_name().is_some_and(|name| name == "store.db") + && path + .parent() + .and_then(Path::file_name) + .is_some_and(|name| name == session_id) + }) + }) +} + /// The store `session_id`'s holder has open, under whichever workspace. fn store_path(config: &CursorConfig, session_id: &str) -> Option { std::fs::read_dir(config.chats()) @@ -306,11 +329,41 @@ fn modified_ms(path: &Path) -> Option { .ok() } -/// The live Cursor CLI sessions on this computer, for the session list. +/// The live Cursor CLI sessions and Cursor editor conversations on this +/// computer, for the session list. A conversation a `cursor-agent` holds +/// is listed as that CLI session. pub fn discover( config: &CursorConfig, table: &dyn ProcessTable, uid: u32, +) -> Vec { + let mut found = discover_cli(config, table, uid); + let held: std::collections::HashSet = found + .iter() + .map(|session| session.target.id.clone()) + .collect(); + found.extend( + editor::sessions(config, table, uid) + .into_iter() + .filter(|session| !held.contains(&session.session_id)) + .map(|session| DiscoveredSession { + target: Target::new(SessionKind::CursorCli, session.session_id.clone()), + title: editor::title(config, &session.session_id) + .unwrap_or_else(|| session.session_id.clone()), + directory: session.workspace, + updated_at_ms: Some(session.seen_ms), + host: Some(editor::HOST.to_string()), + live: true, + holder: None, + }), + ); + found +} + +fn discover_cli( + config: &CursorConfig, + table: &dyn ProcessTable, + uid: u32, ) -> Vec { held_sessions(config, table, uid) .into_iter() @@ -341,7 +394,7 @@ pub fn discover( .collect() } -/// The live Cursor CLI session `session_id` names, as one listing row. +/// The live Cursor session `session_id` names, as one listing row. pub fn session( config: &CursorConfig, table: &dyn ProcessTable, @@ -353,46 +406,222 @@ pub fn session( .find(|session| session.target.id == session_id) } +/// Whether `session_id` runs now, in a `cursor-agent` that alone holds it +/// or in a Cursor editor window, so a reply command its tools run can +/// reach the core. +pub fn running( + config: &CursorConfig, + table: &dyn ProcessTable, + uid: u32, + session_id: &str, +) -> bool { + held(config, table, uid, session_id).is_some() + || editor::live(config, table, uid, session_id).is_some() +} + /// How many ancestors a hook climbs to its `cursor-agent`: Cursor runs a /// hook through a shell, so it is normally the grandparent. const HOOK_ANCESTRY: usize = 8; -/// Whether the hook process `start` runs under the `cursor-agent` holding -/// `session_id`: the first agent above it is that Cursor process, of this -/// user's, and it is the one process holding that conversation's store -/// open, as discovery requires. The payload's id alone never drains a -/// session's inbox, and neither does Cursor's editor, which reads the same -/// `hooks.json`, or a second Cursor that resumed the same conversation. -pub fn hook_caller_holds( +/// What a hook runs under, when it may hand over a session's messages. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HookCaller { + /// The one `cursor-agent` holding the session's store. + Cli, + /// A Cursor window's extension host (`editor`), which runs the + /// conversation the payload names. + Editor { host: i32 }, +} + +/// What the hook process `start` runs under, for `session_id`: the first +/// agent above the hook, of this user's. Either the `cursor-agent` holding +/// the session, when it is the one process holding that conversation's +/// store open, as discovery requires; or a Cursor editor window's extension +/// host, which runs every conversation of its window and runs their hooks +/// as its children, when the conversation is not another window's or a +/// CLI's (`editor::may_claim`). The editor's own `cursor-agent` worker is +/// walked past. The payload's id alone never drains a session's inbox, +/// and neither does a second Cursor that resumed the same conversation or a +/// hook any other agent runs. +pub fn hook_caller( config: &CursorConfig, table: &dyn ProcessTable, start: i32, session_id: &str, -) -> bool { +) -> Option { let uid = crate::relay::peer::current_uid(); let mut pid = start; for _ in 0..HOOK_ANCESTRY { if pid <= 1 { - return false; + return None; } - let Some(process) = table.process(pid) else { - return false; - }; + let process = table.process(pid)?; if process.uid == uid && is_cursor_process(table, pid) { - return held(config, table, uid, session_id).is_some_and(|held| held.pid == pid); + let holder = held(config, table, uid, session_id).is_some_and(|held| held.pid == pid); + if holder { + return Some(HookCaller::Cli); + } + if !editor::is_editor_worker(table, pid) { + return None; + } + pid = process.parent; + continue; + } + if process.uid == uid && editor::is_editor_host(table, pid) { + return (platform::SUPPORTED + && editor::may_claim(config, table, uid, start, pid, session_id)) + .then_some(HookCaller::Editor { host: pid }); } if crate::relay::hook_agents::is_claude_process(table, pid) || crate::relay::hook_agents::is_other_agent(table, pid) { - return false; + return None; } pid = process.parent; } - false + None +} + +/// Whether the hook process `start` may hand over `session_id`'s messages +/// (`hook_caller`). +pub fn hook_caller_holds( + config: &CursorConfig, + table: &dyn ProcessTable, + start: i32, + session_id: &str, +) -> bool { + hook_caller(config, table, start, session_id).is_some() +} + +/// Claims and records the editor conversation the hook process `start` +/// under `host` runs (`editor::claim`), so delivery and the session list see +/// it live. `false` when it may not, or the record failed: the hook then +/// hands nothing over. +pub fn record_editor_hook( + config: &CursorConfig, + table: &dyn ProcessTable, + start: i32, + host: i32, + payload: &Value, +) -> bool { + payload_session(payload).is_some_and(|session_id| { + editor::claim( + config, + table, + start, + host, + session_id, + editor::Workspace::Window(editor::payload_workspace(payload)), + ) + }) +} + +/// One run of Agent Relay's hook for `payload` in the process `pid`. It +/// first notes a turn starting and retires a hook still waiting after +/// another turn of the conversation (`wait::turn_seen`); a +/// `beforeSubmitPrompt` does only that. +/// 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. +pub fn run_hook( + config: &CursorConfig, + table: &dyn ProcessTable, + pid: i32, + payload: &Value, + watch: Option<&wait::Watch<'_>>, + write: &mut dyn FnMut(&Value) -> bool, +) -> bool { + wait::turn_seen(config, payload); + let Some(session) = payload_session(payload) else { + return false; + }; + if payload["hook_event_name"] == "beforeSubmitPrompt" { + 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) + } + 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), + } + } + } } +fn answer_now( + config: &CursorConfig, + payload: &Value, + write: &mut dyn FnMut(&Value) -> bool, +) -> bool { + let Some(answer) = hook_answer(config, payload) else { + return false; + }; + let written = write(&answer.output); + if written { + drain(&answer.claimed); + } else { + unclaim(config, &answer.claimed); + } + written +} + +/// 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. +pub fn editor_caller( + config: &CursorConfig, + table: &dyn ProcessTable, + pid: i32, + host: i32, +) -> Option { + 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()); + editor::claim( + config, + table, + pid, + host, + &session_id, + editor::Workspace::Fallback(workspace.as_deref()), + ) + .then_some(session_id) +} + +pub mod editor; mod inbox; mod settings; +pub mod wait; pub use inbox::{ drain, hook_answer, payload_session, send, unclaim, HookAnswer, HOOK_EVENTS, HOOK_SUBCOMMAND, }; @@ -427,5 +656,7 @@ mod windows_tests { } } +#[cfg(test)] +mod editor_tests; #[cfg(test)] mod tests; diff --git a/crates/probe/src/relay/cursor_cli/editor.rs b/crates/probe/src/relay/cursor_cli/editor.rs new file mode 100644 index 0000000000..7fa0bfe905 --- /dev/null +++ b/crates/probe/src/relay/cursor_cli/editor.rs @@ -0,0 +1,609 @@ +//! Conversations of the Cursor editor's agent, reached through the same +//! inbox and hooks as Cursor CLI. +//! +//! No process holds an editor conversation's file open: one extension host +//! per window runs every agent conversation of that window, and runs +//! `~/.cursor/hooks.json` hooks as its own children with the conversation +//! in the payload. So the proof is the hook's parent, the kernel's +//! executable for it being Cursor's extension host, and the record +//! Agent Relay's hook leaves of it: which host (pid and start time) last +//! ran a hook for the conversation, and when. The conversation is live +//! while that host runs and has run one of its hooks within `IDLE`. +//! +//! A tool process of the conversation (a shell the agent runs) carries +//! `CURSOR_CONVERSATION_ID`; under a verified extension host it names the +//! caller, the way `CODEX_THREAD_ID` does under a trusted Codex. + +use super::{is_session_id, CursorConfig}; +use crate::relay::existing_session::platform; +use crate::relay::process::ProcessTable; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +/// What the editor's agent names its tool processes' conversation by. +pub const CONVERSATION_VAR: &str = "CURSOR_CONVERSATION_ID"; + +/// How long after its last hook an editor conversation still counts as +/// live in its window: a conversation left open in a window runs no hook +/// until its person starts a turn. +pub(super) const IDLE: Duration = Duration::from_secs(12 * 60 * 60); + +/// What the Cursor editor's agent is called where it hosts a session. +pub const HOST: &str = "Cursor"; + +/// Whether `pid` is a Cursor window's extension host: Cursor's +/// `Cursor Helper (Plugin)` with the `extension-host` title Cursor gives +/// that process on macOS; on Linux, where the title is not set, its +/// `cursor` binary started as the extension host (the crash-reporter type +/// Cursor puts in its environment, or `--type=extensionHost`). Both it and +/// its parent must be Cursor's own binaries +/// (`ProcessTable::is_trusted_cursor`) of one install, and the parent +/// Cursor's main process started as the app (`is_cursor_main`), so a binary +/// merely named after Cursor, Cursor run as a tool (even by the host), or a +/// helper started by Cursor's binary run as plain Node is not. Cursor's +/// other helpers (renderers, its shared process, language servers) are not +/// either. +pub fn is_editor_host(table: &dyn ProcessTable, pid: i32) -> bool { + let Some(executable) = table.executable(pid) else { + return false; + }; + let Some(file) = executable.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if file != "Cursor Helper (Plugin)" && file != "cursor" { + return false; + } + let Some(parent) = table.process(pid).map(|process| process.parent) else { + return false; + }; + if !is_cursor_main(table, parent, &executable) || !table.is_trusted_cursor(&executable) { + return false; + } + let arguments = table.arguments(pid); + let titled = arguments + .iter() + .any(|argument| argument.contains("extension-host")); + let helper = file == "Cursor Helper (Plugin)" + && executable + .to_string_lossy() + .contains("/Contents/Frameworks/Cursor Helper (Plugin).app/"); + let linux = file == "cursor" + && (titled + || arguments + .iter() + .any(|argument| argument == "--type=extensionHost") + || table + .environment_var(pid, "VSCODE_CRASH_REPORTER_PROCESS_TYPE") + .is_some_and(|kind| kind == "extensionHost")); + (helper && titled) || linux +} + +/// Whether `pid` is the main process of the Cursor install `helper` belongs +/// to, started as the app: its executable is that install's main binary +/// (`Cursor.app/Contents/MacOS/Cursor` around the helper's bundle on macOS, +/// the helper's own `cursor` binary on Linux) and Cursor's own; its +/// environment is readable and does not run it as plain Node +/// (`ELECTRON_RUN_AS_NODE`), where it would run any script and could start +/// a helper titled like a host; and it carries none of the switches a +/// helper (`--type=`) or a debugged main process (`--inspect…`, +/// `--remote-debugging…`) does. +fn is_cursor_main(table: &dyn ProcessTable, pid: i32, helper: &Path) -> bool { + let Some(main) = table.executable(pid) else { + return false; + }; + let same_install = if main.file_name().is_some_and(|name| name == "Cursor") { + main.parent() + .filter(|dir| dir.ends_with("Contents/MacOS")) + .and_then(Path::parent) + .is_some_and(|contents| { + helper.starts_with(contents.join("Frameworks/Cursor Helper (Plugin).app")) + }) + } else { + main == helper && main.file_name().is_some_and(|name| name == "cursor") + }; + same_install + && table.is_trusted_cursor(&main) + && table.environment_readable(pid) + && table.environment_var(pid, "ELECTRON_RUN_AS_NODE").is_none() + && !table.arguments(pid).iter().any(|argument| { + argument.starts_with("--type=") + || argument.starts_with("--inspect") + || argument.starts_with("--remote-debugging") + }) +} + +/// Whether `pid` is the `cursor-agent … worker start` a Cursor window's +/// agent extension runs, directly under that window's extension host: it +/// holds no conversation of its own, so a walk goes on past it to the host. +pub fn is_editor_worker(table: &dyn ProcessTable, pid: i32) -> bool { + table + .arguments(pid) + .windows(2) + .any(|pair| pair[0] == "worker" && pair[1] == "start") + && table + .process(pid) + .is_some_and(|process| is_editor_host(table, process.parent)) +} + +/// Whether a process `pid` under the extension host `host` may speak for +/// `session_id`: no `cursor-agent` of `uid`'s holds the conversation, +/// 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. +pub(super) fn may_claim( + config: &CursorConfig, + table: &dyn ProcessTable, + uid: u32, + pid: i32, + host: i32, + session_id: &str, +) -> bool { + if super::held_anywhere(table, uid, session_id) { + return false; + } + let owner = owner(config, table, uid, session_id); + if owner.is_some_and(|owner| owner != host) { + return false; + } + match path_vouches(table, pid, host, session_id) { + Some(Vouch::Named) => true, + Some(Vouch::Launched) => { + owner == Some(host) || recently_active(config, table, host, session_id) + } + None => false, + } +} + +/// How the processes between a caller and its host vouch for a conversation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Vouch { + /// One of them names it in `CURSOR_CONVERSATION_ID`. + Named, + /// None names any, and the caller is what Cursor starts for a hook. + Launched, +} + +/// How long after Cursor last wrote a conversation's transcript a hook +/// that names no conversation may still take it up for its window. +const ACTIVE: Duration = Duration::from_secs(60 * 60); + +/// Whether Cursor wrote `session_id`'s transcript (its own, or a subagent's) +/// within `ACTIVE`, in the data folder of `host`'s window +/// (`window_data_dir`): the conversation is running now. +/// +/// Out of scope, as for every route here: code running as this user. +/// Cursor extensions run as the user inside the trusted extension host, +/// which starts the hooks, so an extension can start what looks like +/// Cursor's hook launch and name any conversation in the payload; Cursor +/// gives a hook nothing that proves which conversation started it. This +/// gate only keeps such a claim to a conversation running now that no +/// running window holds, rather than any a closed or reloaded window left. +fn recently_active( + config: &CursorConfig, + table: &dyn ProcessTable, + host: i32, + session_id: &str, +) -> bool { + let mut editor = CursorConfig::from_env(&|_| None, &config.home); + editor.data_dir = window_data_dir(table, host).unwrap_or(editor.data_dir); + super::inbox::transcripts(&editor, session_id) + .iter() + .filter_map(|path| std::fs::metadata(path).ok()?.modified().ok()) + .any(|written| written.elapsed().map_or(true, |age| age < ACTIVE)) +} + +/// The `CURSOR_DATA_DIR` the Cursor of `host`'s window runs with: from the +/// host's own environment or, where the kernel shows none (macOS hides a +/// titled helper's), its main process's. Never the caller's own, which a +/// hook's starter chooses; `None` means Cursor's default, `~/.cursor`. +fn window_data_dir(table: &dyn ProcessTable, host: i32) -> Option { + let set = |pid: i32| { + table + .environment_var(pid, "CURSOR_DATA_DIR") + .filter(|dir| !dir.trim().is_empty()) + .map(PathBuf::from) + }; + set(host).or_else(|| set(table.process(host)?.parent)) +} + +/// Claims `session_id` for the extension host `host` on behalf of the hook +/// process `pid`, and records it (`record`): under a lock per conversation, +/// so two windows' first hooks cannot both pass `may_claim` and both record +/// themselves. Whoever records second finds the first one's record and is +/// refused. `false` when the claim or the record failed. +pub(super) fn claim( + config: &CursorConfig, + table: &dyn ProcessTable, + pid: i32, + host: i32, + session_id: &str, + workspace: Workspace<'_>, +) -> bool { + if !is_session_id(session_id) { + return false; + } + let dir = records(config); + if std::fs::create_dir_all(&dir).is_err() { + return false; + } + let _ = platform::set_mode(&dir, 0o700); + use platform::OpenOptionsExt; + let Ok(lock) = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(dir.join(format!(".{session_id}.lock"))) + else { + return false; + }; + if fs2::FileExt::lock_exclusive(&lock).is_err() { + return false; + } + let uid = crate::relay::peer::current_uid(); + let claimed = may_claim(config, table, uid, pid, host, session_id) + && record(config, table, host, session_id, workspace); + let _ = fs2::FileExt::unlock(&lock); + claimed +} + +/// How the processes from `pid` up to (not including) `host` vouch for +/// `session_id`, if they do: every one's environment is readable, none names another +/// conversation in `CURSOR_CONVERSATION_ID`, and either one names this one +/// (a tool of it) or none does and `pid` is what Cursor starts for a hook: +/// the host's own child, or the child of the host's `sh -c` or of the +/// window's `cursor-agent … worker start`. Cursor gives its hooks no +/// `CURSOR_CONVERSATION_ID`, only the payload; any deeper process naming +/// none descends from a tool or an extension that dropped it. +fn path_vouches(table: &dyn ProcessTable, pid: i32, host: i32, session_id: &str) -> Option { + let mut named = false; + let mut chain = Vec::new(); + let mut current = pid; + for _ in 0..crate::relay::process::MAX_ANCESTRY { + if current == host { + let launched = match chain.as_slice() { + [] | [_] => true, + [_, launcher] => { + is_editor_worker(table, *launcher) || is_hook_shell(table, *launcher) + } + _ => false, + }; + return if named { + Some(Vouch::Named) + } else { + launched.then_some(Vouch::Launched) + }; + } + if current <= 1 || !table.environment_readable(current) { + return None; + } + if let Some(id) = table.environment_var(current, CONVERSATION_VAR) { + if id.trim().to_lowercase() != session_id { + return None; + } + named = true; + } + let process = table.process(current)?; + chain.push(current); + current = process.parent; + } + None +} + +/// Whether `pid` is a shell running one command string (`sh -c …`), the +/// way Cursor may start a hook command. +fn is_hook_shell(table: &dyn ProcessTable, pid: i32) -> bool { + let arguments = table.arguments(pid); + let shell = table + .executable(pid) + .and_then(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + .is_some_and(|name| matches!(name.as_str(), "sh" | "bash" | "dash" | "zsh")); + shell && arguments.get(1).is_some_and(|flag| flag == "-c") +} + +/// Which extension host last ran a hook of a conversation, and when. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(super) struct Record { + pub host_pid: i32, + pub host_start: u64, + /// The window's first workspace folder. + pub workspace: Option, + pub seen_ms: i64, +} + +/// One live editor conversation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorSession { + pub session_id: String, + pub host_pid: i32, + pub workspace: Option, + pub seen_ms: i64, +} + +fn records(config: &CursorConfig) -> PathBuf { + config + .home + .join(".agentworkforce") + .join("desktop") + .join("cursor") + .join("editor") +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|now| i64::try_from(now.as_millis()).ok()) + .unwrap_or(0) +} + +/// Where `workspace` in a `record` call comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Workspace<'a> { + /// The window's own folders, from a hook payload: it replaces any; a + /// payload with none keeps what the same host recorded. + Window(Option<&'a str>), + /// A tool's working directory: used only when the same host recorded + /// none for the conversation. + Fallback(Option<&'a str>), +} + +/// Records that `host`, a verified extension host of this user's, runs +/// `session_id`. The record is written beside and renamed into place, so a +/// reader never sees half of one. Failure only leaves the old record, and +/// answers `false`. +fn record( + config: &CursorConfig, + table: &dyn ProcessTable, + host: i32, + session_id: &str, + workspace: Workspace<'_>, +) -> bool { + if !is_session_id(session_id) { + return false; + } + let Some(process) = table.process(host) else { + return false; + }; + let dir = records(config); + let path = dir.join(format!("{session_id}.json")); + let previous = || { + read(&path) + .filter(|previous| previous.host_pid == host && previous.host_start == process.start) + .and_then(|previous| previous.workspace) + }; + let workspace = match workspace { + Workspace::Window(Some(workspace)) => Some(workspace.to_string()), + Workspace::Window(None) => previous(), + Workspace::Fallback(workspace) => previous().or_else(|| workspace.map(str::to_string)), + }; + let record = Record { + host_pid: host, + host_start: process.start, + workspace, + seen_ms: now_ms(), + }; + if std::fs::create_dir_all(&dir).is_err() { + return false; + } + let _ = platform::set_mode(&dir, 0o700); + static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let write = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let staging = dir.join(format!( + ".{session_id}.json.{}.{write}.tmp", + std::process::id() + )); + let Ok(bytes) = serde_json::to_vec(&record) else { + return false; + }; + use platform::OpenOptionsExt; + let written = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&staging) + .and_then(|mut file| { + file.write_all(&bytes)?; + file.sync_all() + }); + if written.is_err() || std::fs::rename(&staging, &path).is_err() { + let _ = std::fs::remove_file(&staging); + return false; + } + true +} + +fn read(path: &Path) -> Option { + serde_json::from_slice(&std::fs::read(path).ok()?).ok() +} + +/// The extension host a record gives `session_id` to, while that same host +/// still runs, however long since its last hook. +fn owner( + config: &CursorConfig, + table: &dyn ProcessTable, + uid: u32, + session_id: &str, +) -> Option { + let record = read(&records(config).join(format!("{session_id}.json")))?; + hosts(table, uid, &record).then_some(record.host_pid) +} + +/// Whether `record`'s host is still the same extension host of `uid`'s. +fn hosts(table: &dyn ProcessTable, uid: u32, record: &Record) -> bool { + table + .process(record.host_pid) + .is_some_and(|process| process.uid == uid && process.start == record.host_start) + && !table.zombie(record.host_pid) + && is_editor_host(table, record.host_pid) +} + +/// Whether `record`'s host is still the same extension host of `uid`'s and +/// ran a hook of the conversation within `IDLE`. +fn alive(table: &dyn ProcessTable, uid: u32, record: &Record) -> bool { + now_ms().saturating_sub(record.seen_ms) < IDLE.as_millis() as i64 && hosts(table, uid, record) +} + +/// Whether `session_id`'s record still gives it to the extension host +/// `host` started at `host_start`, and a hook of it ran within `IDLE`. +pub(super) fn holds(config: &CursorConfig, session_id: &str, host: i32, host_start: u64) -> bool { + read(&records(config).join(format!("{session_id}.json"))).is_some_and(|record| { + record.host_pid == host + && record.host_start == host_start + && now_ms().saturating_sub(record.seen_ms) < IDLE.as_millis() as i64 + }) +} + +/// The live editor conversation `session_id` names, if its host still runs. +pub fn live( + config: &CursorConfig, + table: &dyn ProcessTable, + uid: u32, + session_id: &str, +) -> Option { + if !platform::SUPPORTED || !is_session_id(session_id) { + return None; + } + let record = read(&records(config).join(format!("{session_id}.json")))?; + alive(table, uid, &record).then(|| EditorSession { + session_id: session_id.to_string(), + host_pid: record.host_pid, + workspace: record.workspace, + seen_ms: record.seen_ms, + }) +} + +/// Every live editor conversation, by id. A record whose host is gone, or +/// whose pid another process now has, is removed. +pub fn sessions(config: &CursorConfig, table: &dyn ProcessTable, uid: u32) -> Vec { + if !platform::SUPPORTED { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(records(config)) else { + return Vec::new(); + }; + let mut found: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let id = path + .file_name()? + .to_str()? + .strip_suffix(".json")? + .to_string(); + if !is_session_id(&id) { + return None; + } + let record = read(&path)?; + if !alive(table, uid, &record) { + let replaced = table + .process(record.host_pid) + .is_some_and(|process| process.start != record.host_start); + if replaced || table.process_gone(record.host_pid) { + let _ = std::fs::remove_file(&path); + } + return None; + } + Some(EditorSession { + session_id: id, + host_pid: record.host_pid, + workspace: record.workspace, + seen_ms: record.seen_ms, + }) + }) + .collect(); + found.sort_by(|a, b| a.session_id.cmp(&b.session_id)); + found +} + +/// The window's first workspace folder, from a hook payload. +pub(super) fn payload_workspace(payload: &Value) -> Option<&str> { + payload["workspace_roots"] + .as_array()? + .iter() + .find_map(|root| root.as_str().filter(|root| !root.trim().is_empty())) +} + +/// The conversation a tool process of the editor's agent names, from its +/// own environment: `CURSOR_CONVERSATION_ID` on `pid` or, where macOS +/// hides an Apple binary's environment, the processes below `host` on its +/// walk up that show one. Processes that name different conversations, or +/// one that is no conversation id, name none. +pub fn tool_conversation(table: &dyn ProcessTable, pid: i32, host: i32) -> Option { + path_conversation(table, pid, host).filter(|id| is_session_id(id)) +} + +/// Every `CURSOR_CONVERSATION_ID` the processes from `pid` up to (not +/// including) `host` show, as one value; any disagreement, or a walk that +/// never reaches `host`, reads as an empty one. +fn path_conversation(table: &dyn ProcessTable, pid: i32, host: i32) -> Option { + let mut found: Option = None; + let mut current = pid; + for _ in 0..crate::relay::process::MAX_ANCESTRY { + if current == host { + return found; + } + if current <= 1 { + return Some(String::new()); + } + if let Some(id) = table.environment_var(current, CONVERSATION_VAR) { + let id = id.trim().to_lowercase(); + if found.as_ref().is_some_and(|seen| *seen != id) { + return Some(String::new()); + } + found = Some(id); + } + let Some(process) = table.process(current) else { + return Some(String::new()); + }; + current = process.parent; + } + Some(String::new()) +} + +/// The first line of the conversation's first prompt, from Cursor's +/// transcript, for its title; `None` when there is no transcript. +pub(super) fn title(config: &CursorConfig, session_id: &str) -> Option { + use std::io::Read; + let path = super::inbox::transcripts(config, session_id) + .into_iter() + .next()?; + let mut head = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(256 << 10) + .read_to_end(&mut head) + .ok()?; + let text = String::from_utf8_lossy(&head); + let entry = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|entry| entry["role"] == "user")?; + let prompt = entry["message"]["content"] + .as_array()? + .iter() + .find_map(|part| part["text"].as_str())?; + let prompt = match prompt.split_once("") { + Some((_, rest)) => rest.split("").next().unwrap_or(rest), + None => prompt, + }; + let line = prompt + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + let mut title: String = line.chars().take(80).collect(); + if line.chars().count() > 80 { + title.push('…'); + } + Some(title) +} diff --git a/crates/probe/src/relay/cursor_cli/editor_tests.rs b/crates/probe/src/relay/cursor_cli/editor_tests.rs new file mode 100644 index 0000000000..928ab95133 --- /dev/null +++ b/crates/probe/src/relay/cursor_cli/editor_tests.rs @@ -0,0 +1,842 @@ +// The editor route shares the inbox's file modes; it is not available on +// Windows (`windows_tests`). +#![cfg(unix)] + +use super::editor::*; +use super::inbox::*; +use super::*; +use crate::relay::inject::InjectOutcome; +use crate::relay::process::{FakeProcess, FakeProcesses}; +use serde_json::json; + +// The waiting hook's tests (`wait::tests`) run in this world too. +pub(super) const SESSION: &str = "9daab59d-0f1f-4c29-9f5d-717d3ecca68f"; +const OTHER: &str = "97d763c9-121c-4438-a6c8-596ba4288c37"; +pub(super) const HOST_PID: i32 = 650; + +pub(super) fn uid() -> u32 { + crate::relay::peer::current_uid() +} + +fn helper() -> PathBuf { + PathBuf::from( + "/Applications/Cursor.app/Contents/Frameworks/Cursor Helper (Plugin).app/Contents/MacOS/Cursor Helper (Plugin)", + ) +} + +/// A Cursor window's extension host, as `ps` shows its title. +pub(super) fn host(start: u64) -> FakeProcess { + FakeProcess { + parent: 600, + uid: uid(), + start, + executable: Some(helper()), + arguments: vec!["Cursor Helper (Plugin): extension-host api [1-2]".into()], + ..FakeProcess::default() + } +} + +pub(super) fn shell(parent: i32, environment: &[(&str, &str)]) -> FakeProcess { + FakeProcess { + parent, + uid: uid(), + start: 100, + executable: Some(PathBuf::from("/bin/zsh")), + arguments: vec!["/bin/zsh".into()], + environment: environment + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + cwd: Some(PathBuf::from("/work/api")), + ..FakeProcess::default() + } +} + +pub(super) fn world() -> (tempfile::TempDir, CursorConfig, FakeProcesses) { + let temp = tempfile::tempdir().unwrap(); + let config = CursorConfig::from_env(&|_| None, temp.path()); + let table = FakeProcesses::default(); + table.insert( + 600, + FakeProcess { + parent: 1, + uid: uid(), + start: 10, + executable: Some(PathBuf::from( + "/Applications/Cursor.app/Contents/MacOS/Cursor", + )), + arguments: vec!["/Applications/Cursor.app/Contents/MacOS/Cursor".into()], + ..FakeProcess::default() + }, + ); + table.insert( + 601, + FakeProcess { + parent: 1, + uid: uid(), + start: 10, + executable: Some(PathBuf::from("/usr/share/cursor/cursor")), + arguments: vec!["/usr/share/cursor/cursor".into()], + ..FakeProcess::default() + }, + ); + table.insert(HOST_PID, host(20)); + table.trust_cursor(helper()); + table.trust_cursor("/Applications/Cursor.app/Contents/MacOS/Cursor"); + table.trust_cursor("/usr/share/cursor/cursor"); + for session in [SESSION, OTHER] { + std::fs::create_dir_all(own_transcript(&config, session).parent().unwrap()).unwrap(); + std::fs::write(own_transcript(&config, session), b"").unwrap(); + } + (temp, config, table) +} + +/// Where Cursor keeps a conversation's own transcript; `world` writes one +/// for each conversation, so both read as running now. +fn own_transcript(config: &CursorConfig, session: &str) -> PathBuf { + config + .data_dir + .join("projects/work-api/agent-transcripts") + .join(session) + .join(format!("{session}.jsonl")) +} + +pub(super) fn hook_payload(event: &str, session: &str) -> Value { + json!({ + "hook_event_name": event, + "conversation_id": session, + "session_id": session, + "status": "completed", + "workspace_roots": ["/work/api"], + }) +} + +#[test] +fn only_a_windows_extension_host_is_the_editor() { + let (_temp, _config, table) = world(); + assert!(is_editor_host(&table, HOST_PID)); + // The main process, a renderer and a language server are Cursor too, + // but none runs the agent's hooks. + assert!(!is_editor_host(&table, 600)); + table.insert( + 651, + FakeProcess { + arguments: vec!["Cursor Helper (Renderer)".into()], + ..host(20) + }, + ); + assert!(!is_editor_host(&table, 651)); + table.insert( + 652, + FakeProcess { + arguments: vec![ + helper().to_string_lossy().into_owned(), + "--type=utility".into(), + ], + ..host(20) + }, + ); + assert!(!is_editor_host(&table, 652)); + // A binary that merely names itself after the helper is not. + table.insert( + 653, + FakeProcess { + executable: Some(PathBuf::from("/tmp/Cursor Helper (Plugin)")), + ..host(20) + }, + ); + assert!(!is_editor_host(&table, 653)); + // Linux runs the extension host as Cursor's own binary, untitled: a + // Node utility process Cursor marks as the extension host in its + // environment, or the older forked `--type=extensionHost`. + let linux = |arguments: &[&str], kind: &str| FakeProcess { + executable: Some(PathBuf::from("/usr/share/cursor/cursor")), + arguments: arguments + .iter() + .map(|argument| argument.to_string()) + .collect(), + environment: [( + "VSCODE_CRASH_REPORTER_PROCESS_TYPE".to_string(), + kind.to_string(), + )] + .into_iter() + .collect(), + parent: 601, + ..host(20) + }; + let utility = [ + "/usr/share/cursor/cursor", + "--type=utility", + "--utility-sub-type=node.mojom.NodeService", + ]; + table.insert(654, linux(&utility, "extensionHost")); + assert!(is_editor_host(&table, 654)); + table.insert( + 655, + linux(&["/usr/share/cursor/cursor", "--type=extensionHost"], ""), + ); + assert!(is_editor_host(&table, 655)); + // Its pty host and file watcher are the same kind of process. + table.insert(656, linux(&utility, "ptyHost")); + assert!(!is_editor_host(&table, 656)); + // Only Cursor's own binaries, under Cursor's main process: not one + // merely named `cursor`, nor Cursor run by a shell with the host's + // environment. + table.insert( + 657, + FakeProcess { + executable: Some(PathBuf::from("/tmp/cursor")), + ..linux(&["/tmp/cursor", "--type=extensionHost"], "extensionHost") + }, + ); + assert!(!is_editor_host(&table, 657)); + table.insert(700, shell(600, &[])); + table.insert( + 658, + FakeProcess { + parent: 700, + ..linux(&["/usr/share/cursor/cursor", "--version"], "extensionHost") + }, + ); + assert!(!is_editor_host(&table, 658)); + // Nor Cursor's binary the extension host runs itself, which inherits + // the host's environment. + table.insert( + 659, + FakeProcess { + parent: 654, + ..linux(&["/usr/share/cursor/cursor", "--version"], "extensionHost") + }, + ); + assert!(!is_editor_host(&table, 659)); + // Nor a Linux host whose parent is another binary than its own. + table.insert( + 660, + FakeProcess { + parent: 600, + ..linux(&utility, "extensionHost") + }, + ); + assert!(!is_editor_host(&table, 660)); +} + +#[test] +fn a_helper_started_by_cursor_run_as_node_is_not_the_editor() { + let (_temp, _config, table) = world(); + let main = |environment: &[(&str, &str)], arguments: &[&str], executable: &str| FakeProcess { + parent: 1, + uid: uid(), + start: 10, + executable: Some(PathBuf::from(executable)), + arguments: arguments + .iter() + .map(|argument| argument.to_string()) + .collect(), + environment: environment + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ..FakeProcess::default() + }; + let app = "/Applications/Cursor.app/Contents/MacOS/Cursor"; + let under = |parent: i32| FakeProcess { parent, ..host(20) }; + // `ELECTRON_RUN_AS_NODE=1 Cursor parent.js` spawning the signed helper + // with a host's title. + table.insert( + 610, + main(&[("ELECTRON_RUN_AS_NODE", "1")], &[app, "parent.js"], app), + ); + table.insert(611, under(610)); + assert!(!is_editor_host(&table, 611)); + // A main process whose environment cannot be read proves nothing. + table.insert(612, main(&[], &[app], app)); + table.insert(613, under(612)); + table.unreadable_environment.lock().unwrap().insert(612); + assert!(!is_editor_host(&table, 613)); + // Nor one being debugged. + table.insert(614, main(&[], &[app, "--inspect=9229"], app)); + table.insert(615, under(614)); + assert!(!is_editor_host(&table, 615)); + table.insert(630, main(&[], &[app, "--remote-debugging-port=9222"], app)); + table.insert(631, under(630)); + assert!(!is_editor_host(&table, 631)); + // Nor a helper whose parent is the same helper binary. + table.insert(632, host(20)); + table.insert(633, under(632)); + assert!(!is_editor_host(&table, 633)); + // Nor another install's main process, even a trusted one. + let other = "/Applications/Cursor 2.app/Contents/MacOS/Cursor"; + table.trust_cursor(other); + table.insert(616, main(&[], &[other], other)); + table.insert(617, under(616)); + assert!(!is_editor_host(&table, 617)); + // A trusted, titled helper outside Cursor's bundle is not either. + let copied = "/Users/someone/tmp/Cursor Helper (Plugin)"; + table.trust_cursor(copied); + table.insert( + 618, + FakeProcess { + executable: Some(PathBuf::from(copied)), + ..host(20) + }, + ); + assert!(!is_editor_host(&table, 618)); +} + +#[test] +fn a_conversation_a_cursor_agent_holds_elsewhere_is_never_the_editors() { + let (temp, config, table) = world(); + // The CLI session keeps its chats under another data folder than the + // one the hook's environment names. + let store = temp + .path() + .join("elsewhere/chats/ws") + .join(SESSION) + .join("store.db"); + std::fs::create_dir_all(store.parent().unwrap()).unwrap(); + std::fs::write(&store, b"").unwrap(); + table.insert( + 690, + FakeProcess { + parent: 1, + uid: uid(), + start: 100, + executable: Some(PathBuf::from("/opt/cursor/bin/cursor-agent")), + open_files: vec![store], + ..FakeProcess::default() + }, + ); + table.insert(700, shell(HOST_PID, &[])); + assert_eq!(hook_caller(&config, &table, 700, SESSION), None); + assert_eq!( + hook_caller(&config, &table, 700, OTHER), + Some(HookCaller::Editor { host: HOST_PID }) + ); +} + +#[test] +fn only_what_cursor_starts_for_a_hook_may_claim_without_naming_the_conversation() { + let (_temp, config, table) = world(); + // A process two levels below the host that names no conversation + // descends from a tool or an extension, not from Cursor's hook launch. + table.insert(700, shell(HOST_PID, &[])); + table.insert(701, shell(700, &[])); + table.insert(702, shell(701, &[])); + assert_eq!(hook_caller(&config, &table, 702, SESSION), None); + // Under the host's `sh -c`, Cursor's hook is. + table.insert( + 710, + FakeProcess { + executable: Some(PathBuf::from("/bin/sh")), + arguments: vec![ + "/bin/sh".into(), + "-c".into(), + "probe relay cursor-hook".into(), + ], + ..shell(HOST_PID, &[]) + }, + ); + table.insert(711, shell(710, &[])); + assert_eq!( + hook_caller(&config, &table, 711, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // A process whose environment cannot be read may be hiding the + // conversation its tool runs. + table.insert(720, shell(HOST_PID, &[(CONVERSATION_VAR, OTHER)])); + table.insert(721, shell(720, &[(CONVERSATION_VAR, SESSION)])); + table.unreadable_environment.lock().unwrap().insert(720); + assert_eq!(hook_caller(&config, &table, 721, SESSION), None); + table.unreadable_environment.lock().unwrap().insert(700); + assert_eq!(hook_caller(&config, &table, 700, SESSION), None); +} + +/// The `cursor-agent` worker a Cursor window's agent extension starts. +fn worker(parent: i32) -> FakeProcess { + FakeProcess { + parent, + uid: uid(), + start: 100, + executable: Some(PathBuf::from("/opt/cursor/bin/cursor-agent")), + arguments: [ + "cursor-agent", + "--endpoint", + "https://api2.cursor.sh", + "worker", + "start", + ] + .iter() + .map(|argument| argument.to_string()) + .collect(), + ..FakeProcess::default() + } +} + +#[test] +fn a_hook_under_the_editors_worker_is_the_editors() { + let (_temp, config, table) = world(); + table.insert(720, worker(HOST_PID)); + table.insert(721, shell(720, &[])); + assert_eq!( + hook_caller(&config, &table, 721, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // A worker under anything but an extension host ends the walk. + table.insert(730, shell(600, &[])); + table.insert(731, worker(730)); + table.insert(732, shell(731, &[])); + assert_eq!(hook_caller(&config, &table, 732, SESSION), None); +} + +#[test] +fn a_conversation_another_window_runs_is_not_this_windows() { + let (_temp, config, table) = world(); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &hook_payload("postToolUse", SESSION), + ); + table.insert(660, host(30)); + table.insert(661, shell(660, &[])); + assert_eq!(hook_caller(&config, &table, 661, SESSION), None); + // Its own window's next hook still is. + table.insert(700, shell(HOST_PID, &[])); + assert_eq!( + hook_caller(&config, &table, 700, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // Once that window is gone, another may take the conversation up. + table.remove(HOST_PID); + assert_eq!( + hook_caller(&config, &table, 661, SESSION), + Some(HookCaller::Editor { host: 660 }) + ); +} + +#[test] +fn a_tool_of_one_conversation_cannot_hook_as_another() { + let (_temp, config, table) = world(); + // `echo '{…OTHER…}' | probe relay cursor-hook` from SESSION's shell. + table.insert(701, shell(HOST_PID, &[(CONVERSATION_VAR, SESSION)])); + table.insert(702, shell(701, &[])); + assert_eq!(hook_caller(&config, &table, 702, OTHER), None); + assert_eq!( + hook_caller(&config, &table, 702, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // Overriding the variable on the hook does not hide the shell's. + table.insert(703, shell(701, &[(CONVERSATION_VAR, OTHER)])); + assert_eq!(hook_caller(&config, &table, 703, OTHER), None); +} + +#[test] +fn a_hook_the_extension_host_runs_is_the_editors() { + let (_temp, config, table) = world(); + assert_eq!( + hook_caller(&config, &table, HOST_PID, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // Through a shell that did not exec the hook, too. + table.insert(700, shell(HOST_PID, &[])); + assert_eq!( + hook_caller(&config, &table, 700, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); + // A Cursor CLI the editor's agent started is its own session, and a + // hook under it is the CLI's, never the editor's. + table.insert( + 710, + FakeProcess { + parent: 700, + uid: uid(), + start: 100, + executable: Some(PathBuf::from("/opt/cursor/bin/cursor-agent")), + ..FakeProcess::default() + }, + ); + table.insert(711, shell(710, &[])); + assert_eq!(hook_caller(&config, &table, 711, SESSION), None); + // Nor is another user's extension host. + table.insert( + 655, + FakeProcess { + uid: uid() + 1, + ..host(20) + }, + ); + assert_eq!(hook_caller(&config, &table, 655, SESSION), None); +} + +#[test] +fn an_editor_conversation_takes_messages_while_its_window_runs() { + let (_temp, config, table) = world(); + // Before any hook of it ran, nothing says the conversation is open. + assert!(matches!( + send(&config, &table, uid(), SESSION, "m-1", "hello"), + InjectOutcome::Waiting(_) + )); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &hook_payload("postToolUse", SESSION), + ); + assert!(running(&config, &table, uid(), SESSION)); + assert!(!running(&config, &table, uid(), OTHER)); + assert!(matches!( + send( + &config, + &table, + uid(), + SESSION, + "m-1", + "[via Agent Relay · ref 1]\nhello" + ), + InjectOutcome::Accepted(_) + )); + let answer = hook_answer(&config, &hook_payload("postToolUse", SESSION)).unwrap(); + assert!(answer.output["additional_context"] + .as_str() + .unwrap() + .contains("hello")); + drain(&answer.claimed); + assert_eq!( + send(&config, &table, uid(), SESSION, "m-1", "hello"), + InjectOutcome::Delivered + ); + // A new extension host in the same pid (the window reloaded) is not + // the one that ran the conversation. + table.insert(HOST_PID, host(21)); + assert!(!running(&config, &table, uid(), SESSION)); + assert!(matches!( + send(&config, &table, uid(), SESSION, "m-2", "again"), + InjectOutcome::Waiting(_) + )); +} + +#[test] +fn a_hook_naming_no_conversation_takes_up_only_one_running_now() { + let (_temp, config, table) = world(); + table.insert(700, shell(HOST_PID, &[])); + // No transcript, or one Cursor last wrote over an hour ago: a closed or + // reloaded window's conversation, which a hook launch cannot take up. + std::fs::remove_file(own_transcript(&config, OTHER)).unwrap(); + assert!(!record_editor_hook( + &config, + &table, + 700, + HOST_PID, + &hook_payload("stop", OTHER) + )); + assert!(!running(&config, &table, uid(), OTHER)); + let old = std::fs::File::create(own_transcript(&config, OTHER)).unwrap(); + old.set_modified(std::time::SystemTime::now() - Duration::from_secs(2 * 60 * 60)) + .unwrap(); + assert!(!record_editor_hook( + &config, + &table, + 700, + HOST_PID, + &hook_payload("stop", OTHER) + )); + // One running now is taken up, and stays its window's while that window + // runs, however old its transcript gets. + assert!(record_editor_hook( + &config, + &table, + 700, + HOST_PID, + &hook_payload("stop", SESSION) + )); + std::fs::File::options() + .write(true) + .open(own_transcript(&config, SESSION)) + .unwrap() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(2 * 60 * 60)) + .unwrap(); + assert!(record_editor_hook( + &config, + &table, + 700, + HOST_PID, + &hook_payload("stop", SESSION) + )); + // A second window's first hook then finds it taken. + table.insert(660, host(30)); + table.insert(661, shell(660, &[])); + assert!(!record_editor_hook( + &config, + &table, + 661, + 660, + &hook_payload("stop", SESSION) + )); + assert_eq!( + live(&config, &table, uid(), SESSION).map(|row| row.host_pid), + Some(HOST_PID) + ); +} + +#[test] +fn the_activity_gate_reads_the_windows_data_folder_never_the_hooks() { + let (temp, config, table) = world(); + // The window's Cursor keeps its data in another folder; its main + // process's environment says so. + let custom = temp.path().join("custom-cursor"); + let mut main = table.processes.lock().unwrap()[&600].clone(); + main.environment.insert( + "CURSOR_DATA_DIR".into(), + custom.to_string_lossy().into_owned(), + ); + table.insert(600, main); + let mut window = config.clone(); + window.data_dir = custom; + std::fs::remove_file(own_transcript(&config, SESSION)).unwrap(); + std::fs::create_dir_all(own_transcript(&window, SESSION).parent().unwrap()).unwrap(); + std::fs::write(own_transcript(&window, SESSION), b"").unwrap(); + table.insert(700, shell(HOST_PID, &[])); + // A hook whose own environment names an empty folder changes nothing. + let mut hook = config.clone(); + hook.data_dir = temp.path().join("empty"); + assert!(record_editor_hook( + &hook, + &table, + 700, + HOST_PID, + &hook_payload("stop", SESSION) + )); + // A fresh transcript in a folder this window does not use is not its + // conversation running now, whatever the hook's environment names. + let mut fake = config.clone(); + fake.data_dir = temp.path().join(".cursor"); + assert!(!record_editor_hook( + &fake, + &table, + 700, + HOST_PID, + &hook_payload("stop", OTHER) + )); +} + +#[test] +fn a_record_whose_host_pid_was_reused_is_removed() { + let (_temp, config, table) = world(); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &hook_payload("postToolUse", SESSION), + ); + let path = config + .home + .join(".agentworkforce/desktop/cursor/editor") + .join(format!("{SESSION}.json")); + assert!(path.is_file()); + table.insert(HOST_PID, shell(1, &[])); + assert!(sessions(&config, &table, uid()).is_empty()); + assert!(!path.exists()); +} + +#[test] +fn a_blank_first_workspace_root_is_skipped() { + assert_eq!( + payload_workspace(&json!({"workspace_roots": ["", " ", "/work/api"]})), + Some("/work/api") + ); + assert_eq!(payload_workspace(&json!({"workspace_roots": [""]})), None); +} + +#[test] +fn a_conversation_idle_past_the_window_is_not_live() { + let (_temp, config, table) = world(); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &hook_payload("stop", SESSION), + ); + let path = config + .home + .join(".agentworkforce/desktop/cursor/editor") + .join(format!("{SESSION}.json")); + let mut record: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + record["seen_ms"] = json!(record["seen_ms"].as_i64().unwrap() - IDLE.as_millis() as i64 - 1); + std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); + assert!(live(&config, &table, uid(), SESSION).is_none()); + assert!(discover(&config, &table, uid()).is_empty()); + // Its window still runs it: another window cannot take it over. + table.insert(660, host(30)); + table.insert(661, shell(660, &[])); + assert_eq!(hook_caller(&config, &table, 661, SESSION), None); + table.insert(700, shell(HOST_PID, &[])); + assert_eq!( + hook_caller(&config, &table, 700, SESSION), + Some(HookCaller::Editor { host: HOST_PID }) + ); +} + +#[test] +fn the_session_list_shows_editor_conversations_named_by_their_first_prompt() { + let (_temp, config, table) = world(); + std::fs::remove_dir_all(own_transcript(&config, SESSION).parent().unwrap()).unwrap(); + let transcripts = config.data_dir.join("projects/work-api/agent-transcripts"); + let parent = "2a1b9a8e-0d0e-4f3c-9d55-8a7b9f1d2c3e"; + std::fs::create_dir_all(transcripts.join(parent).join("subagents")).unwrap(); + std::fs::write( + transcripts.join(parent).join("subagents").join(format!("{SESSION}.jsonl")), + concat!( + r#"{"role":"user","message":{"content":[{"type":"text","text":"now\n\nFix the flaky login test\nand more\n"}]}}"#, + "\n" + ), + ) + .unwrap(); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &hook_payload("postToolUse", SESSION), + ); + let rows = discover(&config, &table, uid()); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].target, Target::new(SessionKind::CursorCli, SESSION)); + assert_eq!(rows[0].title, "Fix the flaky login test"); + assert_eq!(rows[0].directory.as_deref(), Some("/work/api")); + assert_eq!(rows[0].host.as_deref(), Some(HOST)); + assert!(rows[0].live); + assert_eq!( + session(&config, &table, uid(), SESSION).map(|row| row.target.id), + Some(SESSION.to_string()) + ); + // A subagent's transcript settles its claims like a CLI session's. + assert_eq!(transcripts_for(&config, SESSION).len(), 1); +} + +fn transcripts_for(config: &CursorConfig, session: &str) -> Vec { + super::inbox::transcripts(config, session) +} + +/// A real Cursor editor conversation, `RELAY_CURSOR_EDITOR_SESSION`, whose +/// window runs this checkout's hook with the inbox in the disposable home +/// `RELAY_CURSOR_LIVE_HOME`: once a hook of it has run, one message is +/// staged and must be handed over at one of its next tool calls. +#[test] +#[ignore = "needs a Cursor editor conversation running this checkout's hook"] +fn live_editor_conversation_gets_its_message_once() { + let home = PathBuf::from(std::env::var("RELAY_CURSOR_LIVE_HOME").unwrap()); + let session = std::env::var("RELAY_CURSOR_EDITOR_SESSION").unwrap(); + let config = CursorConfig::from_env(&|key| std::env::var(key).ok(), &home); + let table = crate::relay::process::SystemProcesses::default(); + let wait = |what: &str, check: &dyn Fn() -> bool| { + let deadline = std::time::Instant::now() + Duration::from_secs(300); + while !check() { + assert!(std::time::Instant::now() < deadline, "timed out: {what}"); + std::thread::sleep(Duration::from_millis(250)); + } + }; + wait("the conversation is live", &|| { + live(&config, &table, uid(), &session).is_some() + }); + let row = session_row(&config, &table, &session); + eprintln!("listed as {row:?}"); + let text = "[via Agent Relay — from @relay-live-test-nobody to @cursor-editor-live · ref liveeditor01]\nA note from a peer, for a test: the word for this test is PAPAYA-7. No action is needed.\n[end of Agent Relay message · ref liveeditor01]"; + assert!(matches!( + send(&config, &table, uid(), &session, "live-editor-1", text), + InjectOutcome::Accepted(_) + )); + wait("the message is handed over", &|| { + send(&config, &table, uid(), &session, "live-editor-1", text) == InjectOutcome::Delivered + }); + assert!(pending(&config, &session).is_empty()); +} + +fn session_row( + config: &CursorConfig, + table: &dyn ProcessTable, + id: &str, +) -> Option { + session(config, table, uid(), id) +} + +#[test] +fn a_tool_of_the_editors_agent_is_the_conversation_its_environment_names() { + let (_temp, config, table) = world(); + // 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); + 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_eq!( + live(&config, &table, uid(), SESSION).and_then(|row| row.workspace), + Some("/work/api".to_string()) + ); + // A payload without folders keeps it; the window's folder replaces it; + // a later tool's directory does not. + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &json!({ + "hook_event_name": "stop", + "conversation_id": SESSION, + }), + ); + assert_eq!( + live(&config, &table, uid(), SESSION).and_then(|row| row.workspace), + Some("/work/api".to_string()) + ); + record_editor_hook( + &config, + &table, + HOST_PID, + HOST_PID, + &json!({ + "hook_event_name": "postToolUse", + "conversation_id": SESSION, + "workspace_roots": ["/work/web"], + }), + ); + editor_caller(&config, &table, 702, HOST_PID); + assert_eq!( + live(&config, &table, uid(), SESSION).and_then(|row| row.workspace), + Some("/work/web".to_string()) + ); + // Another window's tool naming it is nobody. + table.insert(660, host(30)); + table.insert(661, shell(660, &[(CONVERSATION_VAR, SESSION)])); + assert_eq!(editor_caller(&config, &table, 661, 660), None); + // Nor is a tool whose ancestors name another conversation. + table.insert(705, shell(HOST_PID, &[(CONVERSATION_VAR, OTHER)])); + table.insert(706, shell(705, &[(CONVERSATION_VAR, SESSION)])); + assert_eq!(editor_caller(&config, &table, 706, HOST_PID), None); + // Without the variable, or with one that is no conversation id, nobody. + table.insert(703, shell(HOST_PID, &[])); + 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); + // And never under anything but an extension host. + assert_eq!(editor_caller(&config, &table, 702, 701), None); +} diff --git a/crates/probe/src/relay/cursor_cli/inbox.rs b/crates/probe/src/relay/cursor_cli/inbox.rs index b7a7876953..285650d3a2 100644 --- a/crates/probe/src/relay/cursor_cli/inbox.rs +++ b/crates/probe/src/relay/cursor_cli/inbox.rs @@ -25,7 +25,8 @@ pub(super) const DRAINED_DIR: &str = "drained"; pub(super) const DRAINED_FOR: Duration = Duration::from_secs(24 * 60 * 60); /// How long a claim may stay unsettled while its hook can still be -/// running: Agent Relay's hook entries give Cursor a 10 second timeout. +/// running: a hook answers within moments of its claim, and the one that +/// waits stops claiming a minute before Cursor's timeout (`wait::WAIT`). pub(super) const CLAIM_GRACE: Duration = Duration::from_secs(30); /// How much of the end of Cursor's transcript is read for a claim. @@ -65,8 +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"; +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"; /// 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` @@ -102,8 +102,10 @@ pub fn send( if inbox.join(DRAINED_DIR).join(&name).exists() { return InjectOutcome::Delivered; } - if held(config, table, uid, session_id).is_none() { - return InjectOutcome::Waiting("that Cursor CLI session is not running".into()); + if held(config, table, uid, session_id).is_none() + && super::editor::live(config, table, uid, session_id).is_none() + { + return InjectOutcome::Waiting("that Cursor session is not running".into()); } if let Err(error) = std::fs::create_dir_all(&inbox) { return InjectOutcome::Retry(format!("could not open the session's inbox: {error}")); @@ -205,23 +207,37 @@ pub(super) fn settle_claim( ) } -/// Every transcript Cursor keeps for `session_id`, in any project. +/// Every transcript Cursor keeps for `session_id`, in any project: its own +/// `/.jsonl`, or, for a subagent of the editor's agent, +/// `/subagents/.jsonl`. Subagent folders are listed only when +/// no project has its own transcript. pub(super) fn transcripts(config: &CursorConfig, session_id: &str) -> Vec { - std::fs::read_dir(config.data_dir.join("projects")) - .map(|projects| { - projects + let file = format!("{session_id}.jsonl"); + let Ok(projects) = std::fs::read_dir(config.data_dir.join("projects")) else { + return Vec::new(); + }; + let roots: Vec = projects + .flatten() + .map(|project| project.path().join("agent-transcripts")) + .collect(); + let own: Vec = roots + .iter() + .map(|transcripts| transcripts.join(session_id).join(&file)) + .filter(|path| path.is_file()) + .collect(); + if !own.is_empty() { + return own; + } + roots + .iter() + .filter_map(|transcripts| std::fs::read_dir(transcripts).ok()) + .flat_map(|parents| { + parents .flatten() - .map(|project| { - project - .path() - .join("agent-transcripts") - .join(session_id) - .join(format!("{session_id}.jsonl")) - }) - .filter(|path| path.is_file()) - .collect() + .map(|parent| parent.path().join("subagents").join(&file)) }) - .unwrap_or_default() + .filter(|path| path.is_file()) + .collect() } /// Whether a user turn in the session's transcript carries `text`'s first @@ -264,9 +280,11 @@ pub(super) fn transcript_records(config: &CursorConfig, session_id: &str, text: /// The probe subcommand Cursor runs for Agent Relay's hooks. pub const HOOK_SUBCOMMAND: &str = "relay cursor-hook"; -/// The hook events Agent Relay installs: a tool call mid-turn, and the end -/// of a turn. -pub const HOOK_EVENTS: [&str; 2] = ["postToolUse", "stop"]; +/// The hook events Agent Relay installs: a tool call mid-turn and the end +/// of a turn, which hand messages over, and a prompt about to start a +/// turn, which only retires a hook still waiting after the last one +/// (`wait::turn_seen`). +pub const HOOK_EVENTS: [&str; 3] = ["postToolUse", "stop", "beforeSubmitPrompt"]; /// One queued message, still in the inbox. pub(super) struct Pending { @@ -349,7 +367,7 @@ pub fn payload_session(payload: &Value) -> Option<&str> { /// that holds this session (`hook_caller_holds`). pub fn hook_answer(config: &CursorConfig, payload: &Value) -> Option { let event = payload["hook_event_name"].as_str()?; - if !HOOK_EVENTS.contains(&event) { + if !matches!(event, "postToolUse" | "stop") { return None; } if event == "stop" && payload["status"].as_str().is_some_and(|s| s != "completed") { diff --git a/crates/probe/src/relay/cursor_cli/settings.rs b/crates/probe/src/relay/cursor_cli/settings.rs index 5c689aeac5..fc74ca8e58 100644 --- a/crates/probe/src/relay/cursor_cli/settings.rs +++ b/crates/probe/src/relay/cursor_cli/settings.rs @@ -1,5 +1,6 @@ //! Agent Relay's entries in Cursor CLI's `~/.cursor/hooks.json`. +use super::wait::{WAIT_FLAG, WAIT_TIMEOUT}; use super::{CursorConfig, HOOK_EVENTS, HOOK_SUBCOMMAND, LABEL}; use crate::relay::existing_session::platform; use serde_json::{json, Map, Value}; @@ -8,9 +9,31 @@ use std::path::Path; /// The store key and `settings.get`/`settings.set` name. pub const SETTING: &str = "cursor_direct_delivery"; -/// Agent Relay's hook timeout, in seconds, in each `hooks.json` entry. +/// Agent Relay's hook timeout, in seconds, in each `hooks.json` entry but +/// the waiting `stop` one (`wait::WAIT_TIMEOUT`). const HOOK_TIMEOUT: u64 = 10; +/// The `stop` entry's command: the hook with `--wait`, before `--dev`. +fn waiting(command: &str) -> String { + match command.strip_suffix(" --dev") { + Some(hook) => format!("{hook} {WAIT_FLAG} --dev"), + None => format!("{command} {WAIT_FLAG}"), + } +} + +/// `command` without the `stop` entry's `--wait`. +fn without_wait(command: &str) -> (String, bool) { + let command = command.trim(); + let (hook, dev) = match command.strip_suffix(" --dev") { + Some(hook) => (hook.trim_end(), " --dev"), + None => (command, ""), + }; + match hook.strip_suffix(&format!(" {WAIT_FLAG}")) { + Some(hook) => (format!("{}{dev}", hook.trim_end()), true), + None => (command.to_string(), false), + } +} + /// Installs and removes Agent Relay's hooks in `~/.cursor/hooks.json`. The /// file is the source of truth: the setting is on exactly when a hook /// running this build's subcommand is there. Entries for other commands are @@ -47,7 +70,71 @@ impl CursorSettings { } pub(super) fn is_ours(&self, command: &str) -> bool { - crate::relay::devin::hook_command::is_ours_running(command, &self.command, HOOK_SUBCOMMAND) + crate::relay::devin::hook_command::is_ours_running( + &without_wait(command).0, + &self.command, + HOOK_SUBCOMMAND, + ) + } + + /// Whether this build's waiting `stop` entry is in `hooks.json`. A hook + /// waiting for a message ends once it is not, so turning the setting + /// off stops waiting hooks too. + pub fn waits(&self) -> bool { + self.read_object().is_ok_and(|object| { + object + .get("hooks") + .and_then(|hooks| hooks.get("stop")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry["command"].as_str()) + .any(|command| without_wait(command).1 && self.is_ours(command)) + }) + } + + /// Runs `claim` while this build's waiting `stop` entry is in + /// `hooks.json`, holding the lock turning the setting on or off takes, + /// so once that returns no waiting hook hands anything more over. + /// `None` when the entry is not there or the lock cannot be taken. + pub fn while_waiting(&self, claim: impl FnOnce() -> T) -> Option { + let _held = self.file_lock(false)?; + self.waits().then(claim) + } + + /// The lock over this build's entries across processes: shared while a + /// waiting hook claims, exclusive while the file is edited. + fn file_lock(&self, exclusive: bool) -> Option { + let inbox = self.config.inbox(); + std::fs::create_dir_all(&inbox).ok()?; + let _ = platform::set_mode(&inbox, 0o700); + use platform::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(inbox.join(".hooks.lock")) + .ok()?; + let taken = if exclusive { + fs2::FileExt::lock_exclusive(&file) + } else { + fs2::FileExt::lock_shared(&file) + }; + taken.ok().map(|()| file) + } + + /// Brings this build's entries, where they are in `hooks.json`, up to + /// the ones it installs now, so an install made before the waiting + /// `stop` entry and `beforeSubmitPrompt` gets them. Everything else + /// stays, and a file already up to date is not written. Whether they + /// are there is read under the same lock as turning the setting off + /// takes, so a refresh never puts back entries just taken out. + pub fn refresh(&self) -> Result<(), String> { + if !self.installed() { + return Ok(()); + } + self.edit_when(true, |object| self.owns(object)) } pub(super) fn read_object(&self) -> Result, String> { @@ -67,18 +154,21 @@ impl CursorSettings { } fn hook_enabled(&self) -> bool { - self.read_object().is_ok_and(|object| { - object - .get("hooks") - .and_then(Value::as_object) - .is_some_and(|hooks| { - hooks - .values() - .flat_map(|entries| entries.as_array().into_iter().flatten()) - .filter_map(|entry| entry["command"].as_str()) - .any(|command| self.is_ours(command)) - }) - }) + self.read_object().is_ok_and(|object| self.owns(&object)) + } + + /// Whether any of `object`'s hooks is this build's. + fn owns(&self, object: &Map) -> bool { + object + .get("hooks") + .and_then(Value::as_object) + .is_some_and(|hooks| { + hooks + .values() + .flat_map(|entries| entries.as_array().into_iter().flatten()) + .filter_map(|entry| entry["command"].as_str()) + .any(|command| self.is_ours(command)) + }) } /// Removes this build's entries from every event, and any event left @@ -103,7 +193,10 @@ impl CursorSettings { } /// Adds this build's entry to each of `HOOK_EVENTS`, after `take_out`, - /// so exactly one runs per event. + /// so exactly one runs per event. The `stop` entry waits + /// (`super::wait`): it runs with `--wait`, an hour's timeout and no + /// `loop_limit`, so Cursor neither ends it early nor skips it after + /// five follow-ups in a row. pub(super) fn install_into(&self, object: &mut Map) -> Result<(), String> { object.entry("version").or_insert(json!(1)); let hooks = object.entry("hooks").or_insert_with(|| json!({})); @@ -120,7 +213,15 @@ impl CursorSettings { "~/.cursor/hooks.json has a {event} value that is not a list, so it was left unchanged" )); }; - entries.push(json!({"command": self.command, "timeout": HOOK_TIMEOUT})); + entries.push(if event == "stop" { + json!({ + "command": waiting(&self.command), + "timeout": WAIT_TIMEOUT, + "loop_limit": null, + }) + } else { + json!({"command": self.command, "timeout": HOOK_TIMEOUT}) + }); } Ok(()) } @@ -128,12 +229,26 @@ impl CursorSettings { /// Applies `enable` to `hooks.json` through the symlink, keeping the /// file's mode and a one-time backup, atomically. fn edit(&self, enable: bool) -> Result<(), String> { + self.edit_when(enable, |_| true) + } + + /// `edit`, when the file it read under the lock passes `applies`. It + /// waits for any claim a waiting hook has under way (`while_waiting`), + /// and leaves the file as it is when that lock cannot be taken. + fn edit_when( + &self, + enable: bool, + applies: impl FnOnce(&Map) -> bool, + ) -> Result<(), String> { let _held = self .lock .lock() .unwrap_or_else(|poison| poison.into_inner()); + let _claims = self.file_lock(true).ok_or( + "Agent Relay could not lock its Cursor hooks, so ~/.cursor/hooks.json was left unchanged", + )?; let mut object = self.read_object()?; - if !enable && object.get("hooks").is_none() { + if !applies(&object) || (!enable && object.get("hooks").is_none()) { return Ok(()); } self.take_out(&mut object); @@ -163,10 +278,17 @@ impl CursorSettings { /// the hooks (`cursor_direct_delivery` false), and hooks already in place /// stay as they are. impl crate::relay::agents::SettingsExtension for CursorSettings { + /// On only while this build's waiting `stop` entry is in place, as the + /// setting's wording promises: an earlier install that `refresh` could + /// not bring up to date reads as off, and turning it on tries again and + /// says why it cannot. fn get(&self) -> Map { let mut settings = Map::new(); if self.installed() { - settings.insert(SETTING.into(), Value::Bool(self.hook_enabled())); + settings.insert( + SETTING.into(), + Value::Bool(self.hook_enabled() && self.waits()), + ); } settings } diff --git a/crates/probe/src/relay/cursor_cli/tests.rs b/crates/probe/src/relay/cursor_cli/tests.rs index 117fd441e3..6c792a0898 100644 --- a/crates/probe/src/relay/cursor_cli/tests.rs +++ b/crates/probe/src/relay/cursor_cli/tests.rs @@ -590,8 +590,19 @@ fn the_setting_installs_only_its_own_hooks() { }) .collect(); assert_eq!(ours.len(), 1, "{event}"); - assert_eq!(ours[0]["timeout"], 10); + // The `stop` entry waits: an hour, and no limit on follow-ups. + let expected = if event == "stop" { + json!({ + "command": "/opt/relay/agent-relay-probe relay cursor-hook --wait", + "timeout": 3600, + "loop_limit": null, + }) + } else { + json!({"command": "/opt/relay/agent-relay-probe relay cursor-hook", "timeout": 10}) + }; + assert_eq!(ours[0], &expected, "{event}"); } + assert!(settings.waits()); assert_eq!(written["other"], true); assert!(written["hooks"]["stop"].to_string().contains("audit.sh")); // Backed up once, before the first change. @@ -610,6 +621,192 @@ fn the_setting_installs_only_its_own_hooks() { .to_string() .contains("echo relay cursor-hook")); assert_eq!(settings.get()[SETTING], json!(false)); + assert!(!settings.waits()); +} + +#[test] +fn an_earlier_install_is_brought_up_to_date() { + let (_temp, config, _table) = world(); + std::fs::create_dir_all(config.hooks_file().parent().unwrap()).unwrap(); + let ours = "/opt/relay/agent-relay-probe relay cursor-hook"; + let earlier = json!({"version": 1, "hooks": { + "postToolUse": [{"command": ours, "timeout": 10}], + "stop": [{"command": "./audit.sh"}, {"command": ours, "timeout": 10}], + }}); + std::fs::write( + config.hooks_file(), + serde_json::to_vec_pretty(&earlier).unwrap(), + ) + .unwrap(); + let settings = settings(&config); + // It wakes no editor conversation yet, so it reads as off. + assert_eq!(settings.get()[SETTING], json!(false)); + assert!(!settings.waits()); + settings.refresh().unwrap(); + assert_eq!(settings.get()[SETTING], json!(true)); + let written = hooks(&config); + assert_eq!( + written["hooks"]["stop"], + json!([ + {"command": "./audit.sh"}, + {"command": format!("{ours} --wait"), "timeout": 3600, "loop_limit": null}, + ]) + ); + assert_eq!( + written["hooks"]["beforeSubmitPrompt"], + json!([{"command": ours, "timeout": 10}]) + ); + assert!(settings.waits()); + // Up to date, it is not written again; turned off, it stays off. + let current = std::fs::read(config.hooks_file()).unwrap(); + settings.refresh().unwrap(); + assert_eq!(std::fs::read(config.hooks_file()).unwrap(), current); + settings.set(SETTING, &json!(false)).unwrap().unwrap(); + let off = std::fs::read(config.hooks_file()).unwrap(); + settings.refresh().unwrap(); + assert_eq!(std::fs::read(config.hooks_file()).unwrap(), off); +} + +#[test] +fn an_earlier_install_that_cannot_be_brought_up_to_date_reads_as_off() { + let (_temp, config, _table) = world(); + std::fs::create_dir_all(config.hooks_file().parent().unwrap()).unwrap(); + let ours = "/opt/relay/agent-relay-probe relay cursor-hook"; + let earlier = json!({"version": 1, "hooks": { + "postToolUse": [{"command": ours, "timeout": 10}], + "stop": [{"command": ours, "timeout": 10}], + "beforeSubmitPrompt": "./audit.sh", + }}); + std::fs::write( + config.hooks_file(), + serde_json::to_vec_pretty(&earlier).unwrap(), + ) + .unwrap(); + let before = std::fs::read(config.hooks_file()).unwrap(); + let settings = settings(&config); + let refused = "~/.cursor/hooks.json has a beforeSubmitPrompt value that is not a list, so it was left unchanged"; + assert_eq!(settings.refresh(), Err(refused.to_string())); + assert_eq!(std::fs::read(config.hooks_file()).unwrap(), before); + assert_eq!(settings.get()[SETTING], json!(false)); + // Turning it on tries again and says why it cannot. + assert_eq!( + settings.set(SETTING, &json!(true)), + Some(Err(refused.to_string())) + ); + assert_eq!(std::fs::read(config.hooks_file()).unwrap(), before); +} + +#[test] +fn turning_the_setting_off_waits_for_a_claim_under_way_and_stops_the_next() { + use std::sync::atomic::{AtomicBool, Ordering}; + let (_temp, config, _table) = world(); + std::fs::create_dir_all(config.hooks_file().parent().unwrap()).unwrap(); + let settings = settings(&config); + settings.set(SETTING, &json!(true)).unwrap().unwrap(); + let (claiming, turning_off, off) = ( + AtomicBool::new(false), + AtomicBool::new(false), + AtomicBool::new(false), + ); + std::thread::scope(|scope| { + let setter = scope.spawn(|| { + while !claiming.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + turning_off.store(true, Ordering::SeqCst); + settings.set(SETTING, &json!(false)).unwrap().unwrap(); + off.store(true, Ordering::SeqCst); + }); + // The claim sees turning the setting off start and not get through. + let held = settings.while_waiting(|| { + claiming.store(true, Ordering::SeqCst); + while !turning_off.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let until = std::time::Instant::now() + std::time::Duration::from_millis(300); + while std::time::Instant::now() < until && !off.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + !off.load(Ordering::SeqCst) + }); + assert_eq!(held, Some(true)); + setter.join().unwrap(); + }); + assert!(off.load(Ordering::SeqCst)); + assert_eq!(settings.while_waiting(|| ()), None); +} + +#[test] +fn a_setting_whose_lock_cannot_be_taken_is_left_as_it_is() { + let (_temp, config, _table) = world(); + std::fs::create_dir_all(config.hooks_file().parent().unwrap()).unwrap(); + let settings = settings(&config); + settings.set(SETTING, &json!(true)).unwrap().unwrap(); + let on = std::fs::read(config.hooks_file()).unwrap(); + // The inbox the lock lives in cannot be made. + std::fs::remove_dir_all(config.inbox()).unwrap(); + std::fs::write(config.inbox(), "").unwrap(); + assert_eq!( + settings.set(SETTING, &json!(false)), + Some(Err( + "Agent Relay could not lock its Cursor hooks, so ~/.cursor/hooks.json was left unchanged" + .to_string() + )) + ); + assert_eq!(std::fs::read(config.hooks_file()).unwrap(), on); + 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)); + let never = || false; + let always = || true; + let claiming = |claim: &mut dyn FnMut() -> Option| claim(); + let watch = wait::Watch { + pid: 501, + 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 + .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!( + run(&stop(4)), + (true, vec![json!({"followup_message": "ping"})]) + ); } #[test] @@ -669,6 +866,12 @@ fn hook_commands_are_recognized_per_build() { ); assert!(dev.is_ours("/opt/agent-relay-probe relay cursor-hook --dev")); assert!(!dev.is_ours("/opt/agent-relay-probe relay cursor-hook")); + // The waiting `stop` entry is each build's too. + assert!(settings.is_ours("/opt/relay/agent-relay-probe relay cursor-hook --wait")); + assert!(!settings.is_ours("/opt/relay/agent-relay-probe relay cursor-hook --wait --dev")); + assert!(dev.is_ours("/opt/agent-relay-probe relay cursor-hook --wait --dev")); + assert!(!dev.is_ours("/opt/agent-relay-probe relay cursor-hook --wait")); + assert!(!settings.is_ours("/opt/relay/agent-relay-probe relay cursor-hook --wait --wait")); } #[test] diff --git a/crates/probe/src/relay/cursor_cli/wait.rs b/crates/probe/src/relay/cursor_cli/wait.rs new file mode 100644 index 0000000000..3b41fc4a56 --- /dev/null +++ b/crates/probe/src/relay/cursor_cli/wait.rs @@ -0,0 +1,545 @@ +//! 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. +//! +//! An editor conversation idle in its window runs no hook until its person +//! starts a turn. So Agent Relay's `stop` entry (`relay 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`). +//! +//! 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; +//! - 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 +//! 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`. +//! +//! 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 +//! hook and answered by it, or put back. A message already pending when +//! the turn ends is answered at once, under the same lock, while the window +//! still holds the conversation. The window between a person +//! submitting a prompt and Cursor running its `beforeSubmitPrompt` hooks is +//! Cursor's own and cannot be closed from a hook. + +use super::inbox::{drain, hook_answer, pending, unclaim}; +use super::{editor, payload_session, CursorConfig}; +use crate::relay::existing_session::platform; +use crate::relay::process::ProcessTable; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +/// The flag Agent Relay's `stop` entry runs its hook with. +pub const WAIT_FLAG: &str = "--wait"; + +/// Cursor's timeout for that entry, in seconds: an hour, the longest +/// Cursor takes without warning that a hook is very long. +pub(super) const WAIT_TIMEOUT: u64 = 60 * 60; + +/// How long a hook waits. It stops a minute before Cursor's timeout would +/// end it, so it is never ended between a claim and its answer. +pub const WAIT: Duration = Duration::from_secs(WAIT_TIMEOUT - 60); + +/// How often a waiting hook looks for a message. +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. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct Waiter { + pid: i32, + /// Unique to that run of the hook. + nonce: String, + /// The turn it waits after: its `stop` payload's `generation_id`. + generation: String, +} + +fn dir(config: &CursorConfig) -> PathBuf { + config.inbox().join(WAITING_DIR) +} + +fn waiter_file(config: &CursorConfig, session: &str) -> PathBuf { + dir(config).join(format!("{session}.wait")) +} + +fn generation(payload: &Value) -> Option<&str> { + payload["generation_id"] + .as_str() + .map(str::trim) + .filter(|generation| !generation.is_empty()) +} + +/// Runs `run` holding the conversation's waiting lock; `None` when the +/// lock cannot be taken. +fn locked(config: &CursorConfig, session: &str, run: impl FnOnce() -> T) -> Option { + let dir = dir(config); + std::fs::create_dir_all(&dir).ok()?; + let _ = platform::set_mode(&config.inbox(), 0o700); + let _ = platform::set_mode(&dir, 0o700); + use platform::OpenOptionsExt; + let lock = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(dir.join(format!(".{session}.lock"))) + .ok()?; + fs2::FileExt::lock_exclusive(&lock).ok()?; + let result = run(); + let _ = fs2::FileExt::unlock(&lock); + Some(result) +} + +fn read(config: &CursorConfig, session: &str) -> Option { + serde_json::from_slice(&std::fs::read(waiter_file(config, session)).ok()?).ok() +} + +/// Names `waiter` the conversation's waiting hook. +fn store(config: &CursorConfig, session: &str, waiter: &Waiter) -> bool { + serde_json::to_vec(waiter) + .is_ok_and(|bytes| replace(config, &waiter_file(config, session), &bytes)) +} + +/// Writes `bytes` to `path` in the waiting directory, beside it and +/// renamed into place. +fn replace(config: &CursorConfig, path: &Path, bytes: &[u8]) -> bool { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let staging = dir(config).join(format!(".{name}.{}.tmp", nonce(std::process::id() as i32))); + use platform::OpenOptionsExt; + let written = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&staging) + .and_then(|mut file| file.write_all(bytes)); + if written.is_err() || std::fs::rename(&staging, path).is_err() { + let _ = std::fs::remove_file(&staging); + return false; + } + true +} + +/// 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. +#[derive(Serialize, Deserialize)] +struct CurrentTurn { + version: u32, + generation: String, +} + +fn turn_file(config: &CursorConfig, session: &str) -> PathBuf { + dir(config).join(format!("{session}.turn")) +} + +/// The conversation's current turn. `None` when its file is missing, +/// unreadable, corrupt or of another version: then no hook waits after a +/// turn or claims for one until the next turn starts and writes it again. +fn current_turn(config: &CursorConfig, session: &str) -> Option { + let turn: CurrentTurn = + serde_json::from_slice(&std::fs::read(turn_file(config, session)).ok()?).ok()?; + (turn.version == TURN_VERSION).then_some(turn.generation) +} + +/// 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); + let written = generation.filter(|generation| { + serde_json::to_vec(&CurrentTurn { + version: TURN_VERSION, + generation: generation.to_string(), + }) + .is_ok_and(|bytes| replace(config, &path, &bytes)) + }); + if written.is_none() { + let _ = std::fs::remove_file(&path); + } + written.map(str::to_string) +} + +fn nonce(pid: i32) -> String { + static RUNS: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{pid}-{now}-{}", RUNS.fetch_add(1, Ordering::Relaxed)) +} + +/// Notes a turn starting (`beforeSubmitPrompt`) as the conversation's +/// current one, and retires the conversation's waiting hook unless both it +/// and `payload`'s hook belong to the current turn: Cursor would drop its +/// follow-up once a newer turn owns the chat, and a hook of another turn is +/// either that or a late one. Every run of Agent Relay's hook calls this +/// first. It needs no proof of its caller: retiring and forgetting the turn +/// only leave the conversation's messages for its next hooks. +pub fn turn_seen(config: &CursorConfig, payload: &Value) { + let Some(session) = payload_session(payload) else { + return; + }; + let starts = payload["hook_event_name"] == "beforeSubmitPrompt"; + if !starts && !waiter_file(config, session).exists() { + return; + } + let generation = generation(payload); + locked(config, session, || { + let current = if starts { + start_turn(config, session, generation) + } else { + current_turn(config, session) + }; + let retired = read(config, session).is_some_and(|waiter| { + Some(waiter.generation.as_str()) != current.as_deref() + || Some(waiter.generation.as_str()) != generation + }); + if retired { + let _ = std::fs::remove_file(waiter_file(config, session)); + } + }); +} + +/// 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 { + payload["hook_event_name"] == "stop" + && payload["status"] == "completed" + && payload_session(payload).is_some() + && generation(payload).is_some() +} + +/// What a waiting hook checks besides its conversation's inbox. +pub struct Watch<'a> { + /// The waiting hook's own pid. + pub pid: i32, + /// When it stops waiting. + pub until: Instant, + pub poll: Duration, + /// Whether it was asked to stop. + pub stopping: &'a dyn Fn() -> bool, + /// Whether this build's waiting entry is still in `hooks.json`. + pub installed: &'a dyn Fn() -> bool, + /// Runs a claim while that entry is there, and keeps it there until the + /// claim is answered (`CursorSettings::while_waiting`); `Removed` when + /// it is not. + pub claiming: &'a Claiming<'a>, +} + +/// `Watch::claiming`. +pub type Claiming<'a> = dyn Fn(&mut dyn FnMut() -> Option) -> Option + 'a; + +/// How a waiting hook ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ended { + /// It answered with the conversation's messages. + Answered, + /// The wait ran out. + TimedOut, + /// Its parent or the window's extension host is gone: the window + /// reloaded or closed. + 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`. + Moved, + /// This build's waiting entry left `hooks.json`. + Removed, + /// It was asked to stop. + Stopped, + /// Its answer could not be written, so its claims went back, or its + /// lock could not be taken. + Failed, +} + +/// 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). +pub fn wait( + config: &CursorConfig, + table: &dyn ProcessTable, + host: i32, + payload: &Value, + watch: &Watch<'_>, + write: &mut dyn FnMut(&Value) -> bool, +) -> Ended { + let waiting = match Waiting::new(config, table, host, payload, watch.pid) { + Ok(waiting) => waiting, + Err(ended) => return ended, + }; + // While the window still holds the conversation and its turn is the + // current one, this hook takes over from an older one before it claims, + // so the older one takes nothing more, then answers what is pending now + // at once. Past `watch.until` it takes nothing. + let first = locked(config, waiting.session, || { + interrupted(watch) + .or_else(|| waiting.lost()) + .or_else(|| waiting.stale().then_some(Ended::Retired)) + .or_else(|| { + (watch.claiming)(&mut || { + (!store(config, waiting.session, &waiting.me)) + .then_some(Ended::Failed) + .or_else(|| answer(config, payload, write)) + }) + }) + }); + let ended = match first { + None => return Ended::Failed, + Some(Some(ended)) => ended, + Some(None) => waiting.poll(payload, watch, write), + }; + locked(config, waiting.session, || { + if waiting.mine() { + let _ = std::fs::remove_file(waiter_file(config, waiting.session)); + } + }); + ended +} + +/// Hands the conversation's pending messages over through `write`: claims +/// are tombstoned once the answer was written and put back if not. `None` +/// when nothing could be claimed. +fn answer( + config: &CursorConfig, + payload: &Value, + write: &mut dyn FnMut(&Value) -> bool, +) -> Option { + let answer = hook_answer(config, payload)?; + Some(if write(&answer.output) { + drain(&answer.claimed); + Ended::Answered + } else { + unclaim(config, &answer.claimed); + Ended::Failed + }) +} + +/// One waiting hook: what it waits after, and the window it waits in. +struct Waiting<'a> { + config: &'a CursorConfig, + table: &'a dyn ProcessTable, + session: &'a str, + me: Waiter, + /// The hook's parent when it started. + parent: i32, + host: i32, + host_start: u64, +} + +impl<'a> Waiting<'a> { + fn new( + config: &'a CursorConfig, + table: &'a dyn ProcessTable, + host: i32, + payload: &'a Value, + pid: i32, + ) -> Result, Ended> { + let (Some(session), Some(generation), true) = ( + payload_session(payload), + generation(payload), + waits_after(payload), + ) else { + return Err(Ended::Failed); + }; + let parent = table.process(pid).map(|process| process.parent); + let host_start = table.process(host).map(|process| process.start); + let (Some(parent), Some(host_start)) = (parent, host_start) else { + return Err(Ended::Orphaned); + }; + if !reaches(table, parent, host) { + return Err(Ended::Orphaned); + } + Ok(Waiting { + config, + table, + session, + me: Waiter { + pid, + nonce: nonce(pid), + generation: generation.to_string(), + }, + parent, + host, + host_start, + }) + } + + /// Looks for a message every `watch.poll` until it answers one or may + /// no longer take one. + fn poll( + &self, + payload: &Value, + watch: &Watch<'_>, + write: &mut dyn FnMut(&Value) -> bool, + ) -> Ended { + let mut polls = 0u32; + loop { + let settings = polls.is_multiple_of(SETTINGS_EVERY); + if let Some(ended) = interrupted(watch) + .or_else(|| self.gone()) + .or_else(|| settings.then(|| uninstalled(watch)).flatten()) + { + return ended; + } + if !pending(self.config, self.session).is_empty() { + let taken = locked(self.config, self.session, || { + self.gone() + .or_else(|| (watch.claiming)(&mut || answer(self.config, payload, write))) + }); + match taken { + None => return Ended::Failed, + Some(Some(ended)) => return ended, + Some(None) => {} + } + } + polls = polls.wrapping_add(1); + std::thread::sleep(watch.poll); + } + } + + /// Why this hook may no longer take a message, if it may not. + fn gone(&self) -> Option { + self.lost() + .or_else(|| (!self.mine() || self.stale()).then_some(Ended::Retired)) + } + + /// Whether its window no longer holds 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) { + 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 alive(&self) -> bool { + self.table + .process(self.me.pid) + .map(|process| process.parent) + == Some(self.parent) + && reaches(self.table, self.parent, self.host) + && self + .table + .process(self.host) + .is_some_and(|process| process.start == self.host_start) + && !self.table.zombie(self.host) + } + + /// Whether it is still the conversation's newest waiting hook. + fn mine(&self) -> bool { + read(self.config, self.session).as_ref() == Some(&self.me) + } + + /// Whether the turn it waits after is not the conversation's current + /// one, or which that is cannot be read. + fn stale(&self) -> bool { + current_turn(self.config, self.session).as_deref() != Some(self.me.generation.as_str()) + } +} + +/// Whether `pid` is `host` or runs under it. A hook whose launcher exited +/// was handed to another parent, so it no longer does. +fn reaches(table: &dyn ProcessTable, mut pid: i32, host: i32) -> bool { + for _ in 0..crate::relay::process::MAX_ANCESTRY { + if pid == host { + return true; + } + if pid <= 1 { + return false; + } + let Some(process) = table.process(pid) else { + return false; + }; + pid = process.parent; + } + false +} + +/// Whether the waiting `stop` entry is gone from `hooks.json`, read every +/// `SETTINGS_EVERY` polls while there is nothing to claim; a claim checks +/// it through `Watch::claiming`. +fn uninstalled(watch: &Watch<'_>) -> Option { + (!(watch.installed)()).then_some(Ended::Removed) +} + +/// Whether the hook was asked to stop or its wait ran out. +fn interrupted(watch: &Watch<'_>) -> Option { + if (watch.stopping)() { + Some(Ended::Stopped) + } else if Instant::now() >= watch.until { + Some(Ended::TimedOut) + } else { + None + } +} + +/// SIGTERM, SIGINT and SIGHUP, which ask a waiting hook to stop: it then +/// takes nothing more and ends at its next poll, instead of being ended +/// between a claim and its answer. Windows has no hook that waits. +pub struct Interrupts { + stop: Arc, +} + +impl Interrupts { + pub fn install() -> Interrupts { + let stop = Arc::new(AtomicBool::new(false)); + #[cfg(unix)] + for signal in [ + signal_hook::consts::SIGTERM, + signal_hook::consts::SIGINT, + signal_hook::consts::SIGHUP, + ] { + let _ = signal_hook::flag::register(signal, stop.clone()); + } + Interrupts { stop } + } + + pub fn requested(&self) -> bool { + self.stop.load(Ordering::SeqCst) + } +} + +#[cfg(test)] +#[path = "wait_tests.rs"] +mod tests; diff --git a/crates/probe/src/relay/cursor_cli/wait_tests.rs b/crates/probe/src/relay/cursor_cli/wait_tests.rs new file mode 100644 index 0000000000..3c23b043de --- /dev/null +++ b/crates/probe/src/relay/cursor_cli/wait_tests.rs @@ -0,0 +1,865 @@ +// In the editor tests' world: window 650's extension host, with Cursor's +// hooks (700 and on) directly under it. The route is not available on +// Windows (`windows_tests`). +#![cfg(unix)] + +use super::super::editor_tests::{hook_payload, host, shell, uid, world, HOST_PID, SESSION}; +use super::super::inbox::pending; +use super::super::{record_editor_hook, run_hook, send, CursorConfig}; +use super::*; +use crate::relay::inject::InjectOutcome; +use crate::relay::process::{FakeProcess, FakeProcesses}; +use serde_json::json; +use std::sync::atomic::AtomicBool; + +const HOOK: i32 = 700; +const LONG: Duration = Duration::from_secs(30); + +/// The world with turn gen-1 started, as its prompt's `beforeSubmitPrompt` +/// hook names it. +fn editor() -> (tempfile::TempDir, CursorConfig, FakeProcesses) { + let (temp, config, table) = world(); + for pid in HOOK..HOOK + 3 { + table.insert(pid, shell(HOST_PID, &[])); + } + start(&config, "gen-1"); + (temp, config, table) +} + +fn start(config: &CursorConfig, generation: &str) { + turn_seen(config, &turn("beforeSubmitPrompt", generation)); +} + +fn turn(event: &str, generation: &str) -> Value { + let mut payload = hook_payload(event, SESSION); + payload["generation_id"] = json!(generation); + payload["loop_count"] = json!(0); + payload +} + +fn stop(generation: &str) -> Value { + turn("stop", generation) +} + +/// What a waiting hook is told besides its inbox: whether it was asked to +/// stop, and whether its entry is still installed, as it reads that while +/// there is nothing to claim and as a claim does (`claims`). +struct Flags { + stopping: AtomicBool, + installed: AtomicBool, + claims: AtomicBool, +} + +impl Flags { + fn new() -> Flags { + Flags { + stopping: AtomicBool::new(false), + installed: AtomicBool::new(true), + claims: AtomicBool::new(true), + } + } + + fn claiming(&self, claim: &mut dyn FnMut() -> Option) -> Option { + if self.installed.load(Ordering::SeqCst) && self.claims.load(Ordering::SeqCst) { + claim() + } else { + Some(Ended::Removed) + } + } +} + +fn watching<'a>( + pid: i32, + wait: Duration, + stopping: &'a dyn Fn() -> bool, + installed: &'a dyn Fn() -> bool, + claiming: &'a Claiming<'a>, +) -> Watch<'a> { + Watch { + pid, + until: Instant::now() + wait, + poll: Duration::from_millis(10), + stopping, + installed, + claiming, + } +} + +/// Agent Relay's hook in `pid` for `payload`, as the waiting `stop` entry +/// runs it when `wait` is set: whether it answered, and what it wrote. +fn run( + config: &CursorConfig, + table: &FakeProcesses, + flags: &Flags, + pid: i32, + payload: &Value, + wait: Option, +) -> (bool, Vec) { + let stopping = || flags.stopping.load(Ordering::SeqCst); + let installed = || flags.installed.load(Ordering::SeqCst); + let claiming = |claim: &mut dyn FnMut() -> Option| flags.claiming(claim); + let watch = wait.map(|wait| watching(pid, wait, &stopping, &installed, &claiming)); + let mut written = Vec::new(); + let answered = run_hook(config, table, pid, payload, watch.as_ref(), &mut |output| { + written.push(output.clone()); + true + }); + (answered, written) +} + +/// The hook in `pid` waiting after the completed turn `payload`, once it +/// recorded the conversation as its window's: how it ended and what it +/// wrote, which `writes` says whether it got out. +fn park( + config: &CursorConfig, + table: &FakeProcesses, + flags: &Flags, + pid: i32, + payload: &Value, + wait: Duration, + writes: bool, +) -> (Ended, Vec) { + assert!(record_editor_hook(config, table, pid, HOST_PID, payload)); + let stopping = || flags.stopping.load(Ordering::SeqCst); + let installed = || flags.installed.load(Ordering::SeqCst); + 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 + }); + (ended, written) +} + +fn eventually(what: &str, check: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !check() { + assert!(Instant::now() < deadline, "{what}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn waiting(config: &CursorConfig) -> bool { + waiter_file(config, SESSION).exists() +} + +/// Whether a hook waits and is past its first checks, which it makes under +/// the conversation's lock after naming itself the waiting hook. +fn parked(config: &CursorConfig) -> bool { + waiting(config) && locked(config, SESSION, || ()).is_some() +} + +fn staged(config: &CursorConfig) -> usize { + pending(config, SESSION).len() +} + +fn message(config: &CursorConfig, table: &FakeProcesses, id: &str, text: &str) { + assert!(matches!( + send(config, table, uid(), SESSION, id, text), + InjectOutcome::Accepted(_) + )); +} + +/// A message placed in the inbox without `send`, which needs the window. +fn place(config: &CursorConfig, id: &str, text: &str) { + std::fs::create_dir_all(config.inbox()).unwrap(); + std::fs::write(config.inbox().join(format!("{SESSION}.{id}.msg")), text).unwrap(); +} + +#[test] +fn a_finished_turn_waits_for_its_next_message() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + std::thread::scope(|scope| { + let hook = scope.spawn(|| run(&config, &table, &flags, HOOK, &stop("gen-1"), Some(LONG))); + eventually("the hook waits", || parked(&config)); + std::thread::sleep(Duration::from_millis(100)); + assert!(!hook.is_finished()); + let sent = Instant::now(); + message(&config, &table, "m-1", "ping"); + assert_eq!( + hook.join().unwrap(), + (true, vec![json!({"followup_message": "ping"})]) + ); + assert!(sent.elapsed() < Duration::from_secs(5)); + }); + // Handed over once, and the hook left nothing behind. + assert_eq!( + send(&config, &table, uid(), SESSION, "m-1", "ping"), + InjectOutcome::Delivered + ); + assert!(!waiting(&config)); +} + +#[test] +fn what_is_pending_as_the_turn_ends_is_answered_at_once() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + // The turn's tool call records the conversation as its window's. + assert_eq!( + run( + &config, + &table, + &flags, + HOOK, + &turn("postToolUse", "gen-1"), + None + ), + (false, vec![]) + ); + message(&config, &table, "m-1", "ping"); + let started = Instant::now(); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 1, + &stop("gen-1"), + Some(LONG) + ), + (true, vec![json!({"followup_message": "ping"})]) + ); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(!waiting(&config)); +} + +#[test] +fn only_a_completed_turn_with_its_id_waits() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let mut aborted = stop("gen-1"); + aborted["status"] = json!("aborted"); + let mut unnamed = stop("gen-1"); + unnamed["generation_id"] = Value::Null; + for payload in [aborted, unnamed, turn("postToolUse", "gen-1")] { + let started = Instant::now(); + assert_eq!( + run(&config, &table, &flags, HOOK, &payload, Some(LONG)), + (false, vec![]), + "{payload}" + ); + assert!(started.elapsed() < Duration::from_secs(5), "{payload}"); + assert!(!waiting(&config), "{payload}"); + } +} + +#[test] +fn a_hook_whose_window_went_away_ends_without_claiming() { + let cases: [(&str, fn(&FakeProcesses)); 4] = [ + ("its parent exited", |table| { + table.insert(HOOK, shell(1, &[])) + }), + ("the extension host exited", |table| table.remove(HOST_PID)), + ("the window reloaded", |table| { + table.insert(HOST_PID, host(21)) + }), + ("the extension host is a zombie", |table| { + table.insert( + HOST_PID, + FakeProcess { + zombie: true, + ..host(20) + }, + ) + }), + ]; + for (case, orphan) in cases { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let ended = std::thread::scope(|scope| { + let hook = + scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually(case, || parked(&config)); + orphan(&table); + place(&config, "m-1", "ping"); + hook.join().unwrap() + }); + assert_eq!(ended, (Ended::Orphaned, vec![]), "{case}"); + assert_eq!(staged(&config), 1, "{case}"); + assert!(!waiting(&config), "{case}"); + } +} + +#[test] +fn the_newest_waiting_hook_takes_the_message() { + // The same turn's hook run again, and the next turn's once it started. + for newer in ["gen-1", "gen-2"] { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + std::thread::scope(|scope| { + let older = + scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the older hook waits", || parked(&config)); + let first = read(&config, SESSION).unwrap(); + if newer != "gen-1" { + start(&config, newer); + } + let newest = + scope.spawn(|| run(&config, &table, &flags, HOOK + 1, &stop(newer), Some(LONG))); + eventually("the newer hook takes over", || { + read(&config, SESSION).is_some_and(|waiter| waiter != first) + }); + assert_eq!(older.join().unwrap(), (Ended::Retired, vec![]), "{newer}"); + message(&config, &table, "m-1", "ping"); + assert_eq!( + newest.join().unwrap(), + (true, vec![json!({"followup_message": "ping"})]), + "{newer}" + ); + }); + } +} + +#[test] +fn a_newer_hook_answering_at_once_retires_the_older_one_first() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + // The older hook is held between two of its checks while the newer one + // of the same turn runs. + let hold = AtomicBool::new(false); + let held = AtomicBool::new(false); + let stopping = || { + while hold.load(Ordering::SeqCst) { + held.store(true, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(5)); + } + false + }; + let installed = || true; + let claiming = |claim: &mut dyn FnMut() -> Option| claim(); + std::thread::scope(|scope| { + let older = scope.spawn(|| { + assert!(record_editor_hook( + &config, + &table, + HOOK, + HOST_PID, + &stop("gen-1") + )); + let watch = watching(HOOK, LONG, &stopping, &installed, &claiming); + let mut written = Vec::new(); + let ended = super::wait( + &config, + &table, + HOST_PID, + &stop("gen-1"), + &watch, + &mut |output| { + written.push(output.clone()); + true + }, + ); + (ended, written) + }); + eventually("the older hook waits", || parked(&config)); + hold.store(true, Ordering::SeqCst); + eventually("the older hook is held", || held.load(Ordering::SeqCst)); + message(&config, &table, "m-1", "ping"); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 1, + &stop("gen-1"), + Some(LONG) + ), + (true, vec![json!({"followup_message": "ping"})]) + ); + message(&config, &table, "m-2", "pong"); + hold.store(false, Ordering::SeqCst); + assert_eq!(older.join().unwrap(), (Ended::Retired, vec![])); + }); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); +} + +#[test] +fn a_prompt_starting_a_newer_turn_retires_the_waiting_hook() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + std::thread::scope(|scope| { + let hook = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + // Its hook only retires the waiting one, and says nothing. + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 1, + &turn("beforeSubmitPrompt", "gen-2"), + None + ), + (false, vec![]) + ); + assert_eq!(hook.join().unwrap(), (Ended::Retired, vec![])); + }); + // The newer turn gets the message at its own next boundary. + message(&config, &table, "m-1", "ping"); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 2, + &turn("postToolUse", "gen-2"), + None + ), + (true, vec![json!({"additional_context": "ping"})]) + ); +} + +#[test] +fn a_stop_hook_of_an_older_turn_run_late_neither_claims_nor_waits() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + // Newer turns start, one after another, while gen-1's stop hook is + // still starting. + for number in 1..=40 { + assert_eq!( + run( + &config, + &table, + &flags, + HOOK, + &turn("beforeSubmitPrompt", &format!("gen-{number}")), + None + ), + (false, vec![]) + ); + } + place(&config, "m-1", "ping"); + let started = Instant::now(); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 2, + &stop("gen-1"), + Some(LONG) + ), + (false, vec![]) + ); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); + // The newest turn's own stop hook takes it. + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 2, + &stop("gen-40"), + Some(LONG) + ), + (true, vec![json!({"followup_message": "ping"})]) + ); +} + +#[test] +fn an_unknown_current_turn_takes_nothing_until_the_next_turn_starts() { + let cases: [(&str, fn(&CursorConfig)); 6] = [ + ("its file is missing", |config| { + std::fs::remove_file(turn_file(config, SESSION)).unwrap() + }), + ("its file is corrupt", |config| { + std::fs::write(turn_file(config, SESSION), "{\"version\": 1, \"gen").unwrap() + }), + ("its file is of another version", |config| { + let later = json!({"version": TURN_VERSION + 1, "generation": "gen-1"}); + std::fs::write(turn_file(config, SESSION), later.to_string()).unwrap() + }), + ("its file has no version", |config| { + let unversioned = json!({"generation": "gen-1"}); + std::fs::write(turn_file(config, SESSION), unversioned.to_string()).unwrap() + }), + ("its file is a list", |config| { + std::fs::write(turn_file(config, SESSION), r#"["gen-1"]"#).unwrap() + }), + ("a turn started with no id", |config| { + let mut unnamed = turn("beforeSubmitPrompt", "gen-2"); + unnamed["generation_id"] = Value::Null; + turn_seen(config, &unnamed); + }), + ]; + for (case, change) in cases { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + change(&config); + place(&config, "m-1", "ping"); + let started = Instant::now(); + assert_eq!( + run(&config, &table, &flags, HOOK, &stop("gen-1"), Some(LONG)), + (false, vec![]), + "{case}" + ); + assert!(started.elapsed() < Duration::from_secs(5), "{case}"); + assert_eq!(staged(&config), 1, "{case}"); + assert!(!waiting(&config), "{case}"); + // The next turn to start writes it again, and its stop hook takes + // the message. + start(&config, "gen-3"); + assert_eq!( + serde_json::from_slice::(&std::fs::read(turn_file(&config, SESSION)).unwrap()) + .unwrap(), + json!({"version": TURN_VERSION, "generation": "gen-3"}), + "{case}" + ); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 1, + &stop("gen-3"), + Some(LONG) + ), + (true, vec![json!({"followup_message": "ping"})]), + "{case}" + ); + } +} + +#[test] +fn a_waiting_hook_whose_current_turn_became_unknown_takes_nothing() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let ended = std::thread::scope(|scope| { + let hook = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + std::fs::write(turn_file(&config, SESSION), "not a turn").unwrap(); + place(&config, "m-1", "ping"); + hook.join().unwrap() + }); + assert_eq!(ended, (Ended::Retired, vec![])); + assert_eq!(staged(&config), 1); +} + +#[test] +fn a_hook_past_its_wait_takes_nothing() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + place(&config, "m-1", "ping"); + assert_eq!( + park( + &config, + &table, + &flags, + HOOK, + &stop("gen-1"), + Duration::ZERO, + true + ), + (Ended::TimedOut, vec![]) + ); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); +} + +#[test] +fn a_message_is_handed_over_once_while_a_newer_turn_starts() { + for round in 0..25 { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let (waited, newer) = std::thread::scope(|scope| { + let hook = + scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + let newer = scope.spawn(|| { + run( + &config, + &table, + &flags, + HOOK + 1, + &turn("postToolUse", "gen-2"), + None, + ) + }); + message(&config, &table, "m-1", "ping"); + (hook.join().unwrap(), newer.join().unwrap()) + }); + let handed = waited.1.len() + newer.1.len(); + assert_eq!(handed + staged(&config), 1, "round {round}"); + assert_eq!( + waited.0, + if waited.1.is_empty() { + Ended::Retired + } else { + Ended::Answered + }, + "round {round}" + ); + } +} + +#[test] +fn an_answer_that_did_not_get_out_puts_the_message_back() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let (ended, written) = std::thread::scope(|scope| { + let hook = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, false)); + eventually("the hook waits", || parked(&config)); + message(&config, &table, "m-1", "ping"); + hook.join().unwrap() + }); + assert_eq!(ended, Ended::Failed); + assert_eq!(written, vec![json!({"followup_message": "ping"})]); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); +} + +#[test] +fn a_waiting_hook_ends_when_its_wait_runs_out_or_it_is_told_to() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let started = Instant::now(); + let ended = park( + &config, + &table, + &flags, + HOOK, + &stop("gen-1"), + Duration::from_millis(100), + true, + ); + assert_eq!(ended, (Ended::TimedOut, vec![])); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(!waiting(&config)); + // A message staged afterwards waits for the conversation's next hook. + message(&config, &table, "m-1", "ping"); + assert_eq!(staged(&config), 1); + std::fs::remove_file(config.inbox().join(format!("{SESSION}.m-1.msg"))).unwrap(); + + for (case, flag) in [ + (Ended::Stopped, &flags.stopping), + (Ended::Removed, &flags.installed), + ] { + std::thread::scope(|scope| { + let hook = + scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + flag.store(case == Ended::Stopped, Ordering::SeqCst); + assert_eq!(hook.join().unwrap(), (case, vec![])); + }); + assert!(!waiting(&config), "{case:?}"); + flags.stopping.store(false, Ordering::SeqCst); + flags.installed.store(true, Ordering::SeqCst); + } +} + +#[test] +fn a_hook_whose_entry_was_taken_out_hands_nothing_over() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + // Taken out as the turn ended, with a message pending: only the claim + // sees it gone. + flags.claims.store(false, Ordering::SeqCst); + place(&config, "m-1", "ping"); + assert_eq!( + run(&config, &table, &flags, HOOK, &stop("gen-1"), Some(LONG)), + (false, vec![]) + ); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); + // Taken out while the hook waits, as a message arrives. + flags.claims.store(true, Ordering::SeqCst); + std::fs::remove_file(config.inbox().join(format!("{SESSION}.m-1.msg"))).unwrap(); + let ended = std::thread::scope(|scope| { + let hook = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + flags.claims.store(false, Ordering::SeqCst); + place(&config, "m-2", "pong"); + hook.join().unwrap() + }); + assert_eq!(ended, (Ended::Removed, vec![])); + assert_eq!(staged(&config), 1); + assert!(!waiting(&config)); +} + +/// A real Cursor editor conversation, `RELAY_CURSOR_EDITOR_SESSION`, whose +/// window runs this checkout's hooks with the inbox in the disposable home +/// `RELAY_CURSOR_LIVE_HOME`, its waiting `stop` entry included. For each of +/// `RELAY_CURSOR_WAKE_ROUNDS` rounds (6 by default, one more than Cursor's +/// default limit on follow-ups in a row): once its finished turn's hook +/// waits, the conversation is left idle for `RELAY_CURSOR_WAKE_AFTER` +/// seconds (20), then one message is staged and must be handed over at +/// once, recorded once as a user turn, and followed by a new waiting hook. +#[test] +#[ignore = "needs a Cursor editor conversation running this checkout's waiting hook"] +fn live_editor_conversation_is_woken_by_its_messages() { + let home = std::path::PathBuf::from(std::env::var("RELAY_CURSOR_LIVE_HOME").unwrap()); + let session = std::env::var("RELAY_CURSOR_EDITOR_SESSION").unwrap(); + let number = |key: &str, default: u64| { + std::env::var(key) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + }; + let rounds = number("RELAY_CURSOR_WAKE_ROUNDS", 6); + let idle = Duration::from_secs(number("RELAY_CURSOR_WAKE_AFTER", 20)); + let config = CursorConfig::from_env(&|key| std::env::var(key).ok(), &home); + let table = crate::relay::process::SystemProcesses::default(); + let wait = |what: &str, limit: Duration, check: &dyn Fn() -> bool| { + let started = Instant::now(); + while !check() { + assert!(started.elapsed() < limit, "timed out: {what}"); + std::thread::sleep(Duration::from_millis(50)); + } + started.elapsed() + }; + let run = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + % 1_000_000; + let mut previous = None; + for round in 1..=rounds { + let parked = wait("a hook waits", Duration::from_secs(300), &|| { + read(&config, &session).is_some_and(|waiter| Some(&waiter) != previous.as_ref()) + }); + let waiter = read(&config, &session).unwrap(); + let process = table.process(waiter.pid); + std::thread::sleep(idle); + assert_eq!( + read(&config, &session).as_ref(), + Some(&waiter), + "round {round}" + ); + assert!(table.process(waiter.pid) == process && process.is_some()); + let reference = format!("livewake{run:06}{round:02}"); + let text = format!( + "[via Agent Relay — from @relay-live-test-nobody to @cursor-editor-live · ref {reference}]\nA note from a peer, for a test: reply with only the word PAPAYA-{round} and end your turn. No other action is needed.\n[end of Agent Relay message · ref {reference}]" + ); + let id = format!("live-wake-{run}-{round}"); + let sent = Instant::now(); + let outcome = send(&config, &table, uid(), &session, &id, &text); + assert!( + matches!(outcome, InjectOutcome::Accepted(_)), + "round {round}: {outcome:?}" + ); + wait( + "the message is handed over", + Duration::from_secs(30), + &|| send(&config, &table, uid(), &session, &id, &text) == InjectOutcome::Delivered, + ); + let handed = sent.elapsed(); + wait("the waiting hook ends", Duration::from_secs(30), &|| { + table.process(waiter.pid) != process + }); + wait( + "its transcript records the turn", + Duration::from_secs(120), + &|| super::super::inbox::transcript_records(&config, &session, &text), + ); + let recorded = sent.elapsed(); + let turns = super::super::inbox::transcripts(&config, &session) + .iter() + .map(|path| { + std::fs::read_to_string(path) + .unwrap_or_default() + .matches(&format!("· ref {reference}]\\nA note")) + .count() + }) + .sum::(); + assert_eq!(turns, 1, "round {round}: recorded once"); + assert!(pending(&config, &session).is_empty()); + eprintln!( + "round {round}: hook {} waited {parked:?} before this round, idle {idle:?}; handed over {handed:?} after staging, recorded {recorded:?} after staging", + waiter.pid + ); + previous = Some(waiter); + } +} + +fn record(config: &CursorConfig) -> std::path::PathBuf { + config + .home + .join(".agentworkforce/desktop/cursor/editor") + .join(format!("{SESSION}.json")) +} + +#[test] +fn what_is_pending_is_left_when_the_window_went_away_before_the_claim() { + // Between the hook recording the conversation and its first claim. + let cases: [(&str, fn(&FakeProcesses, &CursorConfig), Ended); 3] = [ + ( + "its parent exited", + |table, _| table.insert(HOOK, shell(1, &[])), + Ended::Orphaned, + ), + ( + "the window reloaded", + |table, _| table.insert(HOST_PID, host(21)), + Ended::Moved, + ), + ( + "its record is gone", + |_, config| std::fs::remove_file(record(config)).unwrap(), + Ended::Moved, + ), + ]; + for (case, change, expected) in cases { + let (_temp, config, table) = editor(); + assert!(record_editor_hook( + &config, + &table, + HOOK, + HOST_PID, + &stop("gen-1") + )); + place(&config, "m-1", "ping"); + change(&table, &config); + let (never, always) = (|| false, || true); + let claiming = |claim: &mut dyn FnMut() -> Option| claim(); + let watch = watching(HOOK, LONG, &never, &always, &claiming); + let mut written = Vec::new(); + let ended = super::wait( + &config, + &table, + HOST_PID, + &stop("gen-1"), + &watch, + &mut |output| { + written.push(output.clone()); + true + }, + ); + assert_eq!((ended, written), (expected, vec![]), "{case}"); + assert_eq!(staged(&config), 1, "{case}"); + assert!(!waiting(&config), "{case}"); + } +} + +#[test] +fn a_conversation_no_longer_given_to_its_window_ends_the_wait() { + let cases: [(&str, fn(&std::path::Path)); 2] = [ + ("its record is gone", |path| { + std::fs::remove_file(path).unwrap() + }), + ("no hook of it ran within the idle window", |path| { + let mut record: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + record["seen_ms"] = + json!(record["seen_ms"].as_i64().unwrap() - editor::IDLE.as_millis() as i64 - 1); + std::fs::write(path, serde_json::to_vec(&record).unwrap()).unwrap(); + }), + ]; + for (case, change) in cases { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + let ended = std::thread::scope(|scope| { + let hook = + scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-1"), LONG, true)); + eventually("the hook waits", || parked(&config)); + change(&record(&config)); + place(&config, "m-1", "ping"); + hook.join().unwrap() + }); + assert_eq!(ended, (Ended::Moved, vec![]), "{case}"); + assert_eq!(staged(&config), 1, "{case}"); + } +} diff --git a/crates/probe/src/relay/hook_agents.rs b/crates/probe/src/relay/hook_agents.rs index f733e26cc5..95c9fcc0e8 100644 --- a/crates/probe/src/relay/hook_agents.rs +++ b/crates/probe/src/relay/hook_agents.rs @@ -42,6 +42,7 @@ pub fn is_copilot_process(table: &dyn ProcessTable, pid: i32) -> bool { /// Claude Code session further up started it. pub fn is_other_agent(table: &dyn ProcessTable, pid: i32) -> bool { crate::relay::cursor_cli::is_cursor_process(table, pid) + || crate::relay::cursor_cli::editor::is_editor_host(table, pid) || is_copilot_process(table, pid) || crate::relay::devin::is_devin_process(table, pid) || crate::relay::opencode::is_opencode_process(table, pid) diff --git a/crates/probe/src/relay/local.rs b/crates/probe/src/relay/local.rs index 9a00911de4..c25c79caba 100644 --- a/crates/probe/src/relay/local.rs +++ b/crates/probe/src/relay/local.rs @@ -433,7 +433,7 @@ impl LocalSessions { .map(|session| Target::new(SessionKind::MuseOpen, session.session_id)) .ok_or(CallerMiss::NotASession); } - if let Some(caller) = lock_held::caller(&self.config, table, current, process) { + if let Some(caller) = lock_held::caller(&self.config, table, pid, current, process) { return caller; } let trusted = own @@ -6015,6 +6015,148 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo .can_reply(&muse_target)); } + #[cfg(unix)] + #[test] + fn a_cursor_editor_tool_is_its_conversation_never_the_session_that_started_cursor() { + // A Cursor window (main process 690, extension host 700) started + // from the Claude Code session at pid 300; the tools of its agent + // inherit the outer ids. + let world = world(); + let uid = crate::relay::peer::current_uid(); + for pid in [300, 310, 320] { + let mut process = world.table.processes.lock().unwrap()[&pid].clone(); + process.uid = uid; + world.table.insert(pid, process); + } + let helper = "/Applications/Cursor.app/Contents/Frameworks/Cursor Helper (Plugin).app/Contents/MacOS/Cursor Helper (Plugin)"; + let main = "/Applications/Cursor.app/Contents/MacOS/Cursor"; + world.table.trust_cursor(helper); + world.table.trust_cursor(main); + world.table.insert( + 690, + FakeProcess { + parent: 320, + uid, + start: 690, + executable: Some(main.into()), + ..FakeProcess::default() + }, + ); + let host = |start: u64| FakeProcess { + parent: 690, + uid, + start, + executable: Some(helper.into()), + arguments: vec!["Cursor Helper (Plugin): extension-host api [1-2]".into()], + ..FakeProcess::default() + }; + world.table.insert(700, host(700)); + let conversation = "9daab59d-0f1f-4c29-9f5d-717d3ecca68f"; + let codex_thread = "0199a213-81c0-7800-8aa1-bbab2a035a53"; + let tool = |pid: i32, environment: &[(&str, &str)]| FakeProcess { + parent: 700, + uid, + start: pid as u64, + environment: environment + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ..FakeProcess::default() + }; + world.table.insert( + 710, + tool( + 710, + &[ + (cursor_cli::editor::CONVERSATION_VAR, conversation), + ("CODEX_THREAD_ID", codex_thread), + ], + ), + ); + world + .table + .insert(720, tool(720, &[("CODEX_THREAD_ID", codex_thread)])); + let mut config = world.sessions.config.clone(); + 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( + &config.cursor, + world.table.as_ref(), + 700, + 700, + &serde_json::json!({"hook_event_name": "postToolUse", "conversation_id": conversation}), + )); + assert_eq!(sessions.caller_target(710).ok(), Some(target.clone())); + // Without the conversation's own id, an inherited Codex thread or + // the Claude Code session above names nobody. + assert!(matches!( + sessions.caller_target(720), + Err(CallerMiss::NotASession) + )); + let injector = LocalInjector::new( + config.clone(), + world.table.clone(), + CodexRouter::new(Arc::new(UnusedCodexRoutes)), + ServingPath::default(), + ); + assert!(injector.can_reply(&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 { + parent, + uid, + start: pid as u64, + executable: Some("/opt/cursor/bin/cursor-agent".into()), + arguments: ["cursor-agent", "worker", "start"] + .iter() + .map(|argument| argument.to_string()) + .collect(), + ..FakeProcess::default() + }; + world.table.insert(730, worker(730, 700)); + world.table.insert( + 731, + FakeProcess { + parent: 730, + ..tool(731, &[(cursor_cli::editor::CONVERSATION_VAR, conversation)]) + }, + ); + assert_eq!(sessions.caller_target(731).ok(), Some(target.clone())); + // A session-less worker under anything else ends the walk: its + // tools are nobody, never the Claude Code session that started it. + world.table.insert(740, tool(740, &[])); + world.table.insert(741, worker(741, 740)); + world.table.insert( + 742, + FakeProcess { + parent: 741, + ..tool(742, &[(cursor_cli::editor::CONVERSATION_VAR, conversation)]) + }, + ); + assert!(matches!( + sessions.caller_target(742), + Err(CallerMiss::NotASession) + )); + // A reloaded window is another extension host. + world.table.insert(700, host(701)); + assert!(!injector.can_reply(&target)); + } + #[test] fn an_opencode_shell_answers_as_the_session_its_opencode_holds() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/probe/src/relay/local/lock_held.rs b/crates/probe/src/relay/local/lock_held.rs index 83addf84ba..3e1ede1a0c 100644 --- a/crates/probe/src/relay/local/lock_held.rs +++ b/crates/probe/src/relay/local/lock_held.rs @@ -32,29 +32,43 @@ pub(super) fn discover_devin( } } -/// The caller, when the walk reaches a `devin` or `cursor-agent` process of -/// this user's at `current`: the session whose file it holds open, or -/// nobody. A Devin or Cursor command that is not a session never lends a -/// caller the outer session's identity. `None` lets the walk go on. +/// The caller `pid`, when the walk up from it reaches a `devin` or +/// `cursor-agent` process of this user's at `current`: the session whose +/// file it holds open, or nobody. A Devin or Cursor command that is not a +/// session never lends a caller the outer session's identity. At a Cursor +/// editor window's extension host, the conversation the caller's own +/// `CURSOR_CONVERSATION_ID` names, or nobody. `None` lets the walk go on. pub(super) fn caller( config: &LocalConfig, table: &dyn ProcessTable, + pid: i32, current: i32, process: ProcessInfo, ) -> Option> { if process.uid != config.uid { return None; } + if cursor_cli::editor::is_editor_host(table, current) { + let session = cursor_cli::editor_caller(&config.cursor, table, pid, current); + let same = table.process(current) == Some(process); + return Some( + session + .filter(|_| same) + .map(|id| Target::new(SessionKind::CursorCli, id)) + .ok_or(CallerMiss::NotASession), + ); + } let (kind, session) = if devin::is_devin_process(table, current) { ( SessionKind::Devin, devin::held_session(&config.devin, table, current), ) } else if cursor_cli::is_cursor_process(table, current) { - ( - SessionKind::CursorCli, - cursor_cli::held_session(&config.cursor, table, current), - ) + let session = cursor_cli::held_session(&config.cursor, table, current); + if session.is_none() && cursor_cli::editor::is_editor_worker(table, current) { + return None; + } + (SessionKind::CursorCli, session) } else { return None; }; @@ -74,7 +88,7 @@ pub(super) fn can_reply(config: &LocalConfig, table: &dyn ProcessTable, target: match target.kind { SessionKind::Devin => devin::held(&config.devin, table, config.uid, &target.id).is_some(), SessionKind::CursorCli => { - cursor_cli::held(&config.cursor, table, config.uid, &target.id).is_some() + cursor_cli::running(&config.cursor, table, config.uid, &target.id) } _ => false, } diff --git a/crates/probe/src/relay/local/nearest.rs b/crates/probe/src/relay/local/nearest.rs index 6dfa1c52d1..b49899486b 100644 --- a/crates/probe/src/relay/local/nearest.rs +++ b/crates/probe/src/relay/local/nearest.rs @@ -61,7 +61,8 @@ impl LocalSessions { || (self.config.muse_open && crate::relay::muse::is_muse_process(table, current)) || devin::is_devin_process(table, current) - || crate::relay::cursor_cli::is_cursor_process(table, current); + || crate::relay::cursor_cli::is_cursor_process(table, current) + || crate::relay::cursor_cli::editor::is_editor_host(table, current); if agent { return false; } @@ -72,11 +73,12 @@ impl LocalSessions { } /// Whether the nearest agent process of this user's above `pid` (itself - /// included) is a `devin` or `cursor-agent` one, rather than a Claude - /// Code session, a trusted Codex, a Grok leader, OpenCode or Muse. It is - /// what stops an environment tag a nested Devin or Cursor CLI session - /// inherited from an enclosing agent naming that outer session: the file - /// its own process holds decides instead. + /// included) is a `devin`, `cursor-agent` or Cursor editor extension-host + /// one, rather than a Claude Code session, a trusted Codex, a Grok + /// leader, OpenCode or Muse. It is what stops an environment tag a nested + /// Devin or Cursor session inherited from an enclosing agent naming that + /// outer session: the file its own process holds, or the editor's hook + /// record, decides instead. pub(super) fn devin_is_nearest(&self, pid: i32, claude: &claude::Registry) -> bool { let table = &*self.table; let mut current = pid; @@ -90,6 +92,7 @@ impl LocalSessions { if process.uid == self.config.uid { if devin::is_devin_process(table, current) || crate::relay::cursor_cli::is_cursor_process(table, current) + || crate::relay::cursor_cli::editor::is_editor_host(table, current) { return true; } diff --git a/crates/probe/src/relay/local/plugins.rs b/crates/probe/src/relay/local/plugins.rs index a304a412af..06e73d585e 100644 --- a/crates/probe/src/relay/local/plugins.rs +++ b/crates/probe/src/relay/local/plugins.rs @@ -108,7 +108,8 @@ pub fn plugins_with_options( // Agent Relay Dev stages into and drains its own Cursor inbox too. local.cursor = local.cursor.development(development); // Agent Relay's Cursor CLI hooks in ~/.cursor/hooks.json hand staged - // messages to the session at its next tool call or turn end. + // messages to the session at its next tool call or turn end. An + // installed copy is brought up to date off the start path. let cursor_settings = Arc::new(crate::relay::cursor_cli::CursorSettings::new( &local.cursor, crate::relay::cursor_cli::CursorSettings::command_for( @@ -116,6 +117,12 @@ pub fn plugins_with_options( development, ), )); + { + let cursor_settings = cursor_settings.clone(); + let _ = std::thread::Builder::new() + .name("cursor-hooks".into()) + .spawn(move || cursor_settings.refresh()); + } // OpenCode processes started from now on load this build's plugin. A // copy that cannot be rewritten keeps working as the older version. let _ = opencode_settings.refresh_plugin(); diff --git a/crates/probe/src/relay/process.rs b/crates/probe/src/relay/process.rs index a88fd8f2f9..6ec4bf08ac 100644 --- a/crates/probe/src/relay/process.rs +++ b/crates/probe/src/relay/process.rs @@ -18,6 +18,8 @@ use std::path::{Path, PathBuf}; mod macos_args; #[cfg(all(target_os = "macos", test))] mod macos_args_bench; +#[cfg(target_os = "macos")] +mod macos_signing; #[cfg(any(target_os = "macos", test))] mod signature_cache; @@ -87,6 +89,14 @@ pub trait ProcessTable: Send + Sync { /// and on macOS validly signed by OpenAI; on Linux owned by root or this /// user and writable by no one else. fn is_trusted_codex(&self, executable: &Path) -> bool; + /// Whether `executable` is Cursor's own: on macOS validly signed by + /// Cursor's developer (team `VDXQ22DGB9`) in an installed app; on Linux in a folder Cursor's + /// packages install to (`process::linux::in_cursor_install`), owned by + /// root or this user and writable by no one else. Nowhere else, unless + /// a table says. + fn is_trusted_cursor(&self, _executable: &Path) -> bool { + false + } fn arguments(&self, pid: i32) -> Vec; /// One variable of the process's environment. fn environment_var(&self, pid: i32, key: &str) -> Option; @@ -629,7 +639,9 @@ pub fn is_codex_tui(table: &dyn ProcessTable, pid: i32) -> bool { #[derive(Default)] pub struct SystemProcesses { #[cfg(target_os = "macos")] - signatures: macos::SignatureCache, + signatures: macos_signing::SignatureCache, + #[cfg(target_os = "macos")] + cursor_signatures: macos_signing::SignatureCache, #[cfg(windows)] windows: windows::Cache, } @@ -654,7 +666,6 @@ mod macos { use super::*; use std::ffi::{c_int, c_void}; use std::mem::{size_of, zeroed}; - use std::os::unix::fs::MetadataExt; const PROC_ALL_PIDS: u32 = 1; const PROC_PIDVNODEPATHINFO: c_int = 9; @@ -777,57 +788,6 @@ mod macos { Some(buffer) } - /// One file's identity: replacing or changing the file changes it. - #[derive(Clone, Copy, PartialEq, Eq)] - struct Identity { - inode: u64, - mtime: (i64, i64), - ctime: (i64, i64), - } - - fn identity(path: &Path) -> Option { - let m = std::fs::metadata(path).ok()?; - Some(Identity { - inode: m.ino(), - mtime: (m.mtime(), m.mtime_nsec()), - ctime: (m.ctime(), m.ctime_nsec()), - }) - } - - /// Code-signature validation is slow; its result is kept while the path - /// names the same unchanged file. - #[derive(Default)] - pub struct SignatureCache(super::signature_cache::Cache); - - impl SignatureCache { - fn trusted(&self, path: &Path) -> bool { - self.0.trusted(path, identity, verify_codex_signature) - } - } - - /// OpenAI's Codex: identifier `codex`, team `2DC432GLL2`, strictly - /// valid. - fn verify_codex_signature(path: &Path) -> bool { - use security_framework::os::macos::code_signing::{Flags, SecRequirement, SecStaticCode}; - let Some(url) = core_foundation_url(path) else { - return false; - }; - let Ok(code) = SecStaticCode::from_path(&url, Flags::NONE) else { - return false; - }; - let Ok(requirement) = "anchor apple generic and identifier \"codex\" and certificate leaf[subject.OU] = \"2DC432GLL2\"" - .parse::() - else { - return false; - }; - code.check_validity(Flags::STRICT_VALIDATE, &requirement) - .is_ok() - } - - fn core_foundation_url(path: &Path) -> Option { - core_foundation::url::CFURL::from_path(path, false) - } - /// The kernel's BSD record of `pid`: its parent, owner, start time and /// status. A zombie still has one until it is reaped. fn bsd_info(pid: i32) -> Option { @@ -911,6 +871,10 @@ mod macos { && self.signatures.trusted(executable) } + fn is_trusted_cursor(&self, executable: &Path) -> bool { + self.cursor_signatures.trusted_cursor(executable) + } + fn arguments(&self, pid: i32) -> Vec { procargs(pid) .map(|buffer| super::macos_args::decode(&buffer, false).0) @@ -1138,11 +1102,13 @@ pub struct FakeProcess { } /// A process table the tests write, so no test starts a real session. -/// Executables in `trusted_codex` count as trusted Codex. +/// Executables in `trusted_codex` count as trusted Codex, those in +/// `trusted_cursor` as Cursor's own. #[derive(Default)] pub struct FakeProcesses { pub processes: std::sync::Mutex>, pub trusted_codex: std::sync::Mutex>, + pub trusted_cursor: std::sync::Mutex>, pub clock: Option, /// Processes whose environment the kernel would not let this user read. pub unreadable_environment: std::sync::Mutex>, @@ -1161,6 +1127,13 @@ impl FakeProcesses { self.trusted_codex.lock().unwrap().insert(executable.into()); } + pub fn trust_cursor(&self, executable: impl Into) { + self.trusted_cursor + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(executable.into()); + } + fn with(&self, pid: i32, read: impl FnOnce(&FakeProcess) -> T) -> Option { self.processes.lock().unwrap().get(&pid).map(read) } @@ -1191,6 +1164,12 @@ impl ProcessTable for FakeProcesses { executable.file_name().is_some_and(|name| name == "codex") && self.trusted_codex.lock().unwrap().contains(executable) } + fn is_trusted_cursor(&self, executable: &Path) -> bool { + self.trusted_cursor + .lock() + .unwrap_or_else(|error| error.into_inner()) + .contains(executable) + } fn arguments(&self, pid: i32) -> Vec { self.with(pid, |p| p.arguments.clone()).unwrap_or_default() } diff --git a/crates/probe/src/relay/process/linux.rs b/crates/probe/src/relay/process/linux.rs index b0513baf3e..255b10942c 100644 --- a/crates/probe/src/relay/process/linux.rs +++ b/crates/probe/src/relay/process/linux.rs @@ -67,6 +67,14 @@ impl ProcessTable for SystemProcesses { .is_ok_and(|m| (m.uid() == 0 || m.uid() == uid) && m.mode() & 0o022 == 0) } + fn is_trusted_cursor(&self, executable: &Path) -> bool { + let uid = crate::relay::peer::current_uid(); + let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); + in_cursor_install(executable, &mounts) + && fs::metadata(executable) + .is_ok_and(|m| (m.uid() == 0 || m.uid() == uid) && m.mode() & 0o022 == 0) + } + fn arguments(&self, pid: i32) -> Vec { fs::read(proc(pid).join("cmdline")) .map(|bytes| split_nul(&bytes)) @@ -179,3 +187,88 @@ impl ProcessTable for SystemProcesses { StartClock::Ticks } } + +/// Where Cursor's Linux packages put it: the `.deb`/`.rpm` and snap roots, +/// or a mounted Cursor AppImage (a FUSE mount `/tmp/.mount_Cursor…` that +/// `mountinfo` lists). A user can mount their own FUSE file system there, +/// so the AppImage arm only keeps a copied binary in any other folder out. +/// That is accepted knowingly: a program already running as this user is +/// outside the editor route's threat model. +pub fn in_cursor_install(executable: &Path, mountinfo: &str) -> bool { + const ROOTS: [&str; 5] = [ + "/usr/share/cursor", + "/usr/lib/cursor", + "/opt/cursor", + "/opt/Cursor", + "/snap/cursor", + ]; + if ROOTS.iter().any(|root| executable.starts_with(root)) { + return true; + } + mountinfo.lines().any(|line| { + let Some((mount, fstype)) = line.split_once(" - ") else { + return false; + }; + let Some(point) = mount.split(' ').nth(4) else { + return false; + }; + let point = PathBuf::from(unescape_mount(point)); + fstype.starts_with("fuse") + && point.parent() == Some(Path::new("/tmp")) + && point + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(".mount_")) + .is_some_and(|name| name.to_ascii_lowercase().starts_with("cursor")) + && executable.starts_with(&point) + }) +} + +/// `mountinfo` writes a space, tab, newline and backslash as octal escapes. +fn unescape_mount(field: &str) -> String { + field + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_is_trusted_only_where_its_packages_install_it() { + let appimage = "812 30 0:77 / /tmp/.mount_CursorAb12Cd ro,nosuid,nodev - fuse.Cursor-1.7.0-x86_64.AppImage Cursor-1.7.0-x86_64.AppImage ro\n\ + 813 30 0:78 / /tmp/.mount_sleepXy ro - fuse.sleep sleep ro\n\ + 814 30 8:1 / /tmp/.mount_Cursorfake rw - ext4 /dev/sda1 rw\n"; + assert!(in_cursor_install(Path::new("/usr/share/cursor/cursor"), "")); + assert!(in_cursor_install(Path::new("/opt/Cursor/cursor"), "")); + assert!(in_cursor_install( + Path::new("/tmp/.mount_CursorAb12Cd/usr/share/cursor/cursor"), + appimage + )); + assert!(!in_cursor_install(Path::new("/tmp/cursor"), appimage)); + assert!(!in_cursor_install( + Path::new("/home/me/bin/cursor"), + appimage + )); + assert!(!in_cursor_install( + Path::new("/usr/share/cursorfake/cursor"), + "" + )); + // Not a Cursor AppImage, not a FUSE mount, or not mounted at all. + assert!(!in_cursor_install( + Path::new("/tmp/.mount_sleepXy/cursor"), + appimage + )); + assert!(!in_cursor_install( + Path::new("/tmp/.mount_Cursorfake/cursor"), + appimage + )); + assert!(!in_cursor_install( + Path::new("/tmp/.mount_CursorZz/cursor"), + appimage + )); + } +} diff --git a/crates/probe/src/relay/process/macos_signing.rs b/crates/probe/src/relay/process/macos_signing.rs new file mode 100644 index 0000000000..fb9c17bfa4 --- /dev/null +++ b/crates/probe/src/relay/process/macos_signing.rs @@ -0,0 +1,107 @@ +//! macOS code-signature checks for the process table, cached per file. + +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +/// One file's identity: replacing or changing the file changes it. +#[derive(Clone, Copy, PartialEq, Eq)] +struct Identity { + inode: u64, + mtime: (i64, i64), + ctime: (i64, i64), +} + +fn identity(path: &Path) -> Option { + let m = std::fs::metadata(path).ok()?; + Some(Identity { + inode: m.ino(), + mtime: (m.mtime(), m.mtime_nsec()), + ctime: (m.ctime(), m.ctime_nsec()), + }) +} + +/// Code-signature validation is slow; its result is kept while the path +/// names the same unchanged file. +#[derive(Default)] +pub struct SignatureCache(super::signature_cache::Cache); + +impl SignatureCache { + pub(super) fn trusted(&self, path: &Path) -> bool { + self.0.trusted(path, identity, verify_codex_signature) + } + + /// Cursor's own: an installed app file (`installed_app_file`) validly + /// signed by Cursor's developer. + pub(super) fn trusted_cursor(&self, path: &Path) -> bool { + installed_app_file(path) && self.0.trusted(path, identity, verify_cursor_signature) + } +} + +/// Whether `path` sits where an installed app does: under `/Applications` +/// or `~/Applications` (the home Agent Relay keeps this user's files in), +/// owned by root or this user and writable by no one else. A signed copy of +/// Cursor's helper anywhere else is not Cursor's install. +fn installed_app_file(path: &Path) -> bool { + let uid = crate::relay::peer::current_uid(); + let rooted = path.starts_with("/Applications") + || crate::relay::paths::home() + .is_ok_and(|home| path.starts_with(home.join("Applications"))); + rooted + && std::fs::metadata(path) + .is_ok_and(|m| (m.uid() == 0 || m.uid() == uid) && m.mode() & 0o022 == 0) +} + +/// OpenAI's Codex: identifier `codex`, team `2DC432GLL2`, strictly +/// valid. +fn verify_codex_signature(path: &Path) -> bool { + verify_signature( + path, + "anchor apple generic and identifier \"codex\" and certificate leaf[subject.OU] = \"2DC432GLL2\"", + ) +} + +/// Cursor (Anysphere): team `VDXQ22DGB9`, strictly valid. Its helpers +/// keep Electron's identifiers, so the team alone names it. +fn verify_cursor_signature(path: &Path) -> bool { + verify_signature( + path, + "anchor apple generic and certificate leaf[subject.OU] = \"VDXQ22DGB9\"", + ) +} + +fn verify_signature(path: &Path, requirement: &str) -> bool { + use security_framework::os::macos::code_signing::{Flags, SecRequirement, SecStaticCode}; + let Some(url) = core_foundation_url(path) else { + return false; + }; + let Ok(code) = SecStaticCode::from_path(&url, Flags::NONE) else { + return false; + }; + let Ok(requirement) = requirement.parse::() else { + return false; + }; + code.check_validity(Flags::STRICT_VALIDATE, &requirement) + .is_ok() +} + +fn core_foundation_url(path: &Path) -> Option { + core_foundation::url::CFURL::from_path(path, false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn a_file_outside_the_app_folders_is_never_installed() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("Cursor Helper (Plugin)"); + std::fs::write(&file, b"").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!installed_app_file(&file)); + assert!(!installed_app_file(Path::new( + "/Applications-copy/Cursor.app/Contents/MacOS/Cursor" + ))); + } +} From 069c8cfea9b7eac93ef46b0c995b5b1eb517e094 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 11:41:08 -0800 Subject: [PATCH 2/6] feat(cli): set up Cursor from relay init relay init now connects Cursor like the other tools: the Relay MCP server in .cursor/mcp.json or ~/.cursor/mcp.json, the skill in ~/.cursor/skills, and the core's cursor_direct_delivery hooks. Uninstall removes only what init added. Editor delivery needs the core release that follows this PR. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/probe-move.md | 1 + docs/quickstart.md | 5 ++-- packages/cli/src/cli/commands/init.test.ts | 30 +++++++++++++++++-- packages/cli/src/cli/commands/init.ts | 4 +-- .../cli/src/cli/lib/agent-tools-mcp.test.ts | 15 ++++++++-- packages/cli/src/cli/lib/agent-tools-mcp.ts | 16 ++++++++-- packages/cli/src/cli/lib/probe-core.test.ts | 15 ++++++++++ packages/cli/src/cli/lib/probe-core.ts | 8 +++-- packages/cli/src/cli/lib/relay-skill.ts | 5 +++- 10 files changed, 87 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03da732d1b..a56d218531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The core delivers Relay messages to agents in the Cursor editor, not only Cursor CLI, and wakes an idle agent when a message arrives. It uses the Cursor hooks the `cursor_direct_delivery` setting installs, and refreshes installed hooks when it starts. +- `relay init` sets up Cursor: it adds the Relay MCP server, skill and hooks, so messages reach agents in the Cursor editor and Cursor CLI and wake an idle agent. `relay init --uninstall` removes what it added. ## [15.0.2] - 2026-11-01 diff --git a/docs/probe-move.md b/docs/probe-move.md index f2bca3f702..690895246a 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -357,6 +357,7 @@ own, named `--`) and, per tool, through `settings.set`: | OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | | Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | | Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | +| Cursor | `cursor_direct_delivery` | hooks in `~/.cursor/hooks.json` | | Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | `auto_activate` alone never puts an open Pi or OpenCode session on the relay diff --git a/docs/quickstart.md b/docs/quickstart.md index 9cf85e93a8..6b44c2ec87 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -80,8 +80,8 @@ same way `npx skills add` does: machines can join. _My own server_ asks for your server's URL and workspace key (the key is typed hidden). 2. **Which agents.** Relay finds the agent tools installed here and pre-checks - them. Supported: Claude Code, Codex, OpenCode, Grok CLI, Pi, and Devin (the - local CLI). + them. Supported: Claude Code, Codex, OpenCode, Grok CLI, Pi, Devin (the local + CLI), and Cursor (the editor and CLI). 3. **Scope.** _Project_ configures only this folder (files you can commit). _Global_ configures every project on this machine. 4. **Install.** Relay adds its MCP server (`agent-relay mcp`) to each tool, with @@ -97,6 +97,7 @@ same way `npx skills add` does: | Grok CLI | `grok mcp add` | `.grok/config.toml` | `~/.grok/config.toml` | | Pi | `pi mcp add` | `.pi/mcp.json` | `~/.pi/agent/mcp.json` | | Devin | `devin mcp add` | `.devin/mcp_config.json` | `~/.config/devin/mcp_config.json` | +| Cursor | config file | `.cursor/mcp.json` | `~/.cursor/mcp.json` | Project files name the server portably, since teammates share them: `agent-relay mcp` when `agent-relay` is on your PATH, otherwise diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index c2da4295b0..bdeb611b61 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -775,7 +775,7 @@ describe('relay init agent tools (non-interactive)', () => { }); it.each([ - [['--tools', 'cursor'], /Unknown tool for --tools: cursor/], + [['--tools', 'windsurf'], /Unknown tool for --tools: windsurf/], [['--tools', 'gemini,vscode,windsurf'], /Unknown tools/], [['--scope', 'everywhere'], /Expected project or global/], ])('rejects %j before changing anything', async (args, message) => { @@ -870,7 +870,9 @@ describe('relay init guided flow', () => { ); expect(script.shown.join('\n')).not.toContain('not trusted yet'); // Detected tools are pre-checked; every supported tool is offered. - expect(script.shown).toContain('options Claude Code*,Codex,OpenCode,Grok CLI,Pi*,Devin (local CLI)'); + expect(script.shown).toContain( + 'options Claude Code*,Codex,OpenCode,Grok CLI,Pi*,Devin (local CLI),Cursor (editor and CLI)' + ); expect(createWorkspace).not.toHaveBeenCalled(); expect(boundOrigin('rk_live_corp')).toBe('https://relay.corp.test'); expect(mcpJson().mcpServers).toHaveProperty('agent-relay'); @@ -1135,6 +1137,7 @@ function fakeCore( opencode_auto_activate: false, grok_leader: false, devin_direct_delivery: false, + cursor_direct_delivery: false, pi_extension: false, pi_auto_activate: false, ...options.settings, @@ -1790,6 +1793,29 @@ describe('relay init message injection', () => { expect(logs.join('\n')).toContain('messages are held for your approval unless it is "accept"'); }); + it('turns on Cursor delivery and turns it off again on --uninstall', async () => { + installedProbe(); + const core = fakeCore({ running: true, settings: { auto_activate: true } }); + await setup(core.deps).run('--tools', 'cursor'); + expect(core.settings.cursor_direct_delivery).toBe(true); + + const { run, logs } = setup(core.deps); + await run('--uninstall'); + expect(core.settings.cursor_direct_delivery).toBe(false); + expect(logs.join('\n')).toContain('turned off what relay init turned on (cursor_direct_delivery)'); + }); + + it('leaves Cursor delivery on at --uninstall when the person had it on already', async () => { + installedProbe(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true, cursor_direct_delivery: true }, + }); + await setup(core.deps).run('--tools', 'cursor'); + await setup(core.deps).run('--uninstall'); + expect(core.settings.cursor_direct_delivery).toBe(true); + }); + it('turns off on --uninstall exactly what it turned on, and only for the user scope', async () => { installedProbe(); const core = fakeCore({ running: true, settings: { auto_activate: true } }); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index c4da842582..40267b4b8c 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -2502,8 +2502,8 @@ export function registerInitCommand( .description( 'Set up Relay and connect your agent tools without asking: a private server on this machine (default), ' + 'the hosted service (--hosted), or your own server (--url and --key), every installed agent tool, and ' + - 'the Agent Relay core injecting messages into running Claude Code, Codex, OpenCode, Grok, Pi and Devin ' + - 'sessions. --interactive asks each step instead' + 'the Agent Relay core injecting messages into running Claude Code, Codex, OpenCode, Grok, Pi, Devin and ' + + 'Cursor sessions. --interactive asks each step instead' ) .option('--hosted', 'Use the hosted service so agents on other machines can join') .option('--url ', 'Your own Relaycast server (with --key)') diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts index 18d0a98383..a408ae4f32 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.test.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.test.ts @@ -59,6 +59,7 @@ const EXPECTED_PATHS: Record> = { grok: { project: 'my-project/.grok/config.toml', global: 'home/.grok/config.toml' }, pi: { project: 'my-project/.pi/mcp.json', global: 'home/.pi/agent/mcp.json' }, devin: { project: 'my-project/.devin/mcp_config.json', global: 'home/.config/devin/mcp_config.json' }, + cursor: { project: 'my-project/.cursor/mcp.json', global: 'home/.cursor/mcp.json' }, }; /** Unrelated content a person already has in each tool's config. */ @@ -134,7 +135,7 @@ describe('parseToolsFlag', () => { }); it('rejects unknown and unsupported tools', () => { - expect(() => parseToolsFlag('claude,cursor')).toThrow(/Unknown tool for --tools: cursor/); + expect(() => parseToolsFlag('claude,windsurf')).toThrow(/Unknown tool for --tools: windsurf/); expect(() => parseToolsFlag('gemini,vscode')).toThrow(/Unknown tools/); expect(() => parseToolsFlag('all,claude')).toThrow(/cannot be combined/); expect(() => parseToolsFlag(' , ')).toThrow(/needs a value/); @@ -243,6 +244,8 @@ describe('formats', () => { }); it('writes Devin stdio servers with a transport', () => { + installTool(target('cursor'), launch, op('global')); + expect(fs.statSync(path.join(ctx.home, '.cursor', 'mcp.json')).mode & 0o777).toBe(0o600); installTool(target('devin'), launch, op('global')); const doc = JSON.parse(read(path.join(ctx.home, '.config', 'devin', 'mcp_config.json'))); expect(doc.mcpServers['agent-relay']).toMatchObject({ transport: 'stdio', command: launch.command }); @@ -645,5 +648,13 @@ describe('Codex project trust', () => { }); it('offers exactly the supported tools', () => { - expect(TOOL_TARGETS.map((t) => t.id)).toEqual(['claude', 'codex', 'opencode', 'grok', 'pi', 'devin']); + expect(TOOL_TARGETS.map((t) => t.id)).toEqual([ + 'claude', + 'codex', + 'opencode', + 'grok', + 'pi', + 'devin', + 'cursor', + ]); }); diff --git a/packages/cli/src/cli/lib/agent-tools-mcp.ts b/packages/cli/src/cli/lib/agent-tools-mcp.ts index 7d3943b011..849d1fbe27 100644 --- a/packages/cli/src/cli/lib/agent-tools-mcp.ts +++ b/packages/cli/src/cli/lib/agent-tools-mcp.ts @@ -28,7 +28,7 @@ import path from 'node:path'; import { isBundledBunEntrypointPath } from './agent-relay-mcp-command.js'; /** The agent tools `relay init` can connect. */ -export const TOOL_IDS = ['claude', 'codex', 'opencode', 'grok', 'pi', 'devin'] as const; +export const TOOL_IDS = ['claude', 'codex', 'opencode', 'grok', 'pi', 'devin', 'cursor'] as const; export type ToolId = (typeof TOOL_IDS)[number]; /** Where an entry is installed: this project, or the user's global config. */ @@ -262,6 +262,18 @@ export const TOOL_TARGETS: readonly ToolTarget[] = [ ...launch.args, ], }, + { + id: 'cursor', + label: 'Cursor (editor and CLI)', + binary: 'cursor-agent', + format: { kind: 'json', container: 'mcpServers' }, + configPath: (scope, ctx) => + scope === 'project' + ? path.join(ctx.projectRoot, '.cursor', 'mcp.json') + : path.join(ctx.home, '.cursor', 'mcp.json'), + detectPaths: (ctx) => [path.join(ctx.home, '.cursor')], + entry: (launch, env) => stdioEntry(launch, env), + }, ]; // --------------------------------------------------------------------------- @@ -1031,7 +1043,7 @@ export function writeKeepingMode(file: string, contents: string, fallbackMode = /** Mode for a new file: private for user-level tool state, readable otherwise. */ function newFileMode(target: ToolTarget, scope: InstallScope): number { - return scope === 'global' && (target.id === 'claude' || target.id === 'devin') ? 0o600 : 0o644; + return scope === 'global' && ['claude', 'devin', 'cursor'].includes(target.id) ? 0o600 : 0o644; } function writeEntryFile( diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index ff4a4da975..005b196d55 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -253,6 +253,21 @@ describe('enableDelivery', () => { ]); }); + it('turns on Cursor through its hooks setting, and keeps the other tools when an older core refuses it', async () => { + const on = await enableDelivery(client({ auto_activate: true }), ['cursor']); + expect(on.results).toEqual([ + expect.objectContaining({ id: 'cursor', status: 'on', route: 'hooks in ~/.cursor/hooks.json' }), + ]); + expect(on.turnedOn).toEqual(['cursor_direct_delivery']); + + const refused = client({ auto_activate: true }, ['cursor_direct_delivery']); + const outcome = await enableDelivery(refused, ['cursor', 'grok']); + expect(outcome.results).toEqual([ + expect.objectContaining({ id: 'cursor', status: 'failed' }), + expect.objectContaining({ id: 'grok', status: 'on' }), + ]); + }); + it('reports a refused setting as a failure for that tool only', async () => { const core = client({ auto_activate: true }, ['devin_direct_delivery']); const outcome = await enableDelivery(core, ['devin', 'grok']); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index e1b71f572d..c07791e5b9 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -2,8 +2,8 @@ * The Agent Relay core (`agent-relay-probe`): the per-user daemon that finds * the coding-agent sessions running on this machine and injects Relay * messages into them through each tool's own route (Claude Code's session - * inbox, Codex hooks and queue, the OpenCode and Pi plugins, Devin hooks, - * Grok's leader socket). `relay init` installs it from a `probe-v` + * inbox, Codex hooks and queue, the OpenCode and Pi plugins, Devin and Cursor + * hooks, Grok's leader socket). `relay init` installs it from a `probe-v` * GitHub release of this repository, runs it in the background, and turns on * the core's delivery settings for the agent tools the person picked. * @@ -377,6 +377,10 @@ export const PROBE_DELIVERY: Record = { grok: 'grok', pi: 'pi', devin: 'devin', + cursor: 'cursor', }; /** One copy of the skill init put in place. */ @@ -117,7 +118,7 @@ export const defaultRunSkillsCli: RunSkillsCli = (args, { env, cwd }) => { * Where a tool loads global skills, as the `skills` CLI installs them: * Claude Code `~/.claude/skills` (or `$CLAUDE_CONFIG_DIR/skills`); Codex, * OpenCode and Pi the shared `~/.agents/skills`; Grok `~/.grok/skills`; - * Devin `$XDG_CONFIG_HOME/devin/skills`. + * Devin `$XDG_CONFIG_HOME/devin/skills`; Cursor `~/.cursor/skills`. * @param id - The tool * @param ctx - Home and environment the tool resolves against * @returns The tool's global skills folder @@ -131,6 +132,8 @@ export function skillsDirFor(id: ToolId, ctx: ToolContext): string { return path.join(env.GROK_HOME?.trim() || path.join(ctx.home, '.grok'), 'skills'); case 'devin': return path.join(env.XDG_CONFIG_HOME?.trim() || path.join(ctx.home, '.config'), 'devin', 'skills'); + case 'cursor': + return path.join(ctx.home, '.cursor', 'skills'); case 'codex': case 'opencode': case 'pi': From 71ebd1355101dd5eb813df0414f78501e638e9e8 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 12:12:04 -0800 Subject: [PATCH 3/6] fix(probe): address Cursor editor review findings Record a conversation at prompt start so its first tool call can register, answer beforeSubmitPrompt with continue, and lock stale record cleanup. A late hook of a known earlier turn no longer retires the waiting hook, and builds share one hooks.json lock. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- crates/probe/src/relay/cli.rs | 4 +- crates/probe/src/relay/cursor_cli.rs | 16 +++++- crates/probe/src/relay/cursor_cli/editor.rs | 47 +++++++++++------ .../src/relay/cursor_cli/editor_tests.rs | 22 +++++++- crates/probe/src/relay/cursor_cli/settings.rs | 28 +++++++--- crates/probe/src/relay/cursor_cli/tests.rs | 23 +++++++-- crates/probe/src/relay/cursor_cli/wait.rs | 34 +++++++++++-- .../probe/src/relay/cursor_cli/wait_tests.rs | 51 +++++++++++++++++++ crates/probe/src/relay/hook_agents.rs | 6 ++- crates/probe/src/relay/local/nearest.rs | 3 +- crates/probe/src/relay/local/plugins.rs | 8 ++- docs/probe-move.md | 2 +- docs/quickstart.md | 2 +- packages/cli/src/cli/commands/init.test.ts | 13 +++++ 15 files changed, 221 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a56d218531..61855a2e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `relay init` sets up Cursor: it adds the Relay MCP server, skill and hooks, so messages reach agents in the Cursor editor and Cursor CLI and wake an idle agent. `relay init --uninstall` removes what it added. +- `relay init` sets up Cursor, so messages reach agents in the Cursor editor and Cursor CLI and wake an idle agent. +- `relay init --uninstall` removes the Cursor setup it added. ## [15.0.2] - 2026-11-01 diff --git a/crates/probe/src/relay/cli.rs b/crates/probe/src/relay/cli.rs index d3f7e3f704..315b0314d1 100644 --- a/crates/probe/src/relay/cli.rs +++ b/crates/probe/src/relay/cli.rs @@ -491,7 +491,9 @@ pub fn cursor_hook(development: bool, wait: bool) -> Result<()> { ); } if !answered { - stdout.write_all(b"{}\n")?; + let output = crate::relay::cursor_cli::no_answer(&payload); + stdout.write_all(output.to_string().as_bytes())?; + stdout.write_all(b"\n")?; } Ok(()) } diff --git a/crates/probe/src/relay/cursor_cli.rs b/crates/probe/src/relay/cursor_cli.rs index 7f6861a860..075eb8dcb8 100644 --- a/crates/probe/src/relay/cursor_cli.rs +++ b/crates/probe/src/relay/cursor_cli.rs @@ -519,7 +519,8 @@ pub fn record_editor_hook( /// One run of Agent Relay's hook for `payload` in the process `pid`. It /// first notes a turn starting and retires a hook still waiting after /// another turn of the conversation (`wait::turn_seen`); a -/// `beforeSubmitPrompt` does only that. +/// `beforeSubmitPrompt` adds only recording an editor conversation, so its +/// first tool call can register it (`editor_caller`), and hands nothing over. /// 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 @@ -541,6 +542,9 @@ pub fn run_hook( return false; }; if payload["hook_event_name"] == "beforeSubmitPrompt" { + if let Some(HookCaller::Editor { host }) = hook_caller(config, table, pid, session) { + let _ = record_editor_hook(config, table, pid, host, payload); + } return false; } match hook_caller(config, table, pid, session) { @@ -566,6 +570,16 @@ pub fn run_hook( } } +/// What the hook prints when it hands nothing over: `beforeSubmitPrompt` +/// must say the prompt may go on. +pub fn no_answer(payload: &Value) -> Value { + if payload["hook_event_name"] == "beforeSubmitPrompt" { + serde_json::json!({ "continue": true }) + } else { + serde_json::json!({}) + } +} + fn answer_now( config: &CursorConfig, payload: &Value, diff --git a/crates/probe/src/relay/cursor_cli/editor.rs b/crates/probe/src/relay/cursor_cli/editor.rs index 7fa0bfe905..717e47d1c2 100644 --- a/crates/probe/src/relay/cursor_cli/editor.rs +++ b/crates/probe/src/relay/cursor_cli/editor.rs @@ -227,29 +227,44 @@ pub(super) fn claim( if !is_session_id(session_id) { return false; } + locked(config, session_id, || { + let uid = crate::relay::peer::current_uid(); + may_claim(config, table, uid, pid, host, session_id) + && record(config, table, host, session_id, workspace) + }) + .unwrap_or(false) +} + +/// Runs `run` under `session_id`'s record lock. `None` when it cannot be +/// taken. +fn locked(config: &CursorConfig, session_id: &str, run: impl FnOnce() -> T) -> Option { let dir = records(config); - if std::fs::create_dir_all(&dir).is_err() { - return false; - } + std::fs::create_dir_all(&dir).ok()?; let _ = platform::set_mode(&dir, 0o700); use platform::OpenOptionsExt; - let Ok(lock) = std::fs::OpenOptions::new() + let lock = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(false) .mode(0o600) .open(dir.join(format!(".{session_id}.lock"))) - else { - return false; - }; - if fs2::FileExt::lock_exclusive(&lock).is_err() { - return false; - } - let uid = crate::relay::peer::current_uid(); - let claimed = may_claim(config, table, uid, pid, host, session_id) - && record(config, table, host, session_id, workspace); + .ok()?; + fs2::FileExt::lock_exclusive(&lock).ok()?; + let result = run(); let _ = fs2::FileExt::unlock(&lock); - claimed + Some(result) +} + +/// Removes `session_id`'s record if it is still `stale`: under the record +/// lock, so a window that claimed the conversation since `stale` was read +/// keeps its own. +pub(super) fn remove_stale(config: &CursorConfig, session_id: &str, stale: &Record) { + let path = records(config).join(format!("{session_id}.json")); + let _ = locked(config, session_id, || { + if read(&path).as_ref() == Some(stale) { + let _ = std::fs::remove_file(&path); + } + }); } /// How the processes from `pid` up to (not including) `host` vouch for @@ -423,7 +438,7 @@ fn record( true } -fn read(path: &Path) -> Option { +pub(super) fn read(path: &Path) -> Option { serde_json::from_slice(&std::fs::read(path).ok()?).ok() } @@ -510,7 +525,7 @@ pub fn sessions(config: &CursorConfig, table: &dyn ProcessTable, uid: u32) -> Ve .process(record.host_pid) .is_some_and(|process| process.start != record.host_start); if replaced || table.process_gone(record.host_pid) { - let _ = std::fs::remove_file(&path); + remove_stale(config, &id, &record); } return None; } diff --git a/crates/probe/src/relay/cursor_cli/editor_tests.rs b/crates/probe/src/relay/cursor_cli/editor_tests.rs index 928ab95133..a60fa6c6f0 100644 --- a/crates/probe/src/relay/cursor_cli/editor_tests.rs +++ b/crates/probe/src/relay/cursor_cli/editor_tests.rs @@ -112,7 +112,7 @@ pub(super) fn hook_payload(event: &str, session: &str) -> Value { } #[test] -fn only_a_windows_extension_host_is_the_editor() { +fn only_a_window_extension_host_is_the_editor() { let (_temp, _config, table) = world(); assert!(is_editor_host(&table, HOST_PID)); // The main process, a renderer and a language server are Cursor too, @@ -641,6 +641,26 @@ fn a_record_whose_host_pid_was_reused_is_removed() { assert!(!path.exists()); } +#[test] +fn removing_a_stale_record_keeps_a_window_that_claimed_the_conversation_since() { + let (_temp, config, table) = world(); + let payload = hook_payload("postToolUse", SESSION); + record_editor_hook(&config, &table, HOST_PID, HOST_PID, &payload); + let path = config + .home + .join(".agentworkforce/desktop/cursor/editor") + .join(format!("{SESSION}.json")); + let newer = read(&path).unwrap(); + let stale = Record { + host_pid: newer.host_pid + 1, + ..newer.clone() + }; + remove_stale(&config, SESSION, &stale); + assert_eq!(read(&path), Some(newer.clone())); + remove_stale(&config, SESSION, &newer); + assert!(!path.exists()); +} + #[test] fn a_blank_first_workspace_root_is_skipped() { assert_eq!( diff --git a/crates/probe/src/relay/cursor_cli/settings.rs b/crates/probe/src/relay/cursor_cli/settings.rs index fc74ca8e58..ab702da4c3 100644 --- a/crates/probe/src/relay/cursor_cli/settings.rs +++ b/crates/probe/src/relay/cursor_cli/settings.rs @@ -4,7 +4,7 @@ use super::wait::{WAIT_FLAG, WAIT_TIMEOUT}; use super::{CursorConfig, HOOK_EVENTS, HOOK_SUBCOMMAND, LABEL}; use crate::relay::existing_session::platform; use serde_json::{json, Map, Value}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// The store key and `settings.get`/`settings.set` name. pub const SETTING: &str = "cursor_direct_delivery"; @@ -102,19 +102,26 @@ impl CursorSettings { self.waits().then(claim) } - /// The lock over this build's entries across processes: shared while a - /// waiting hook claims, exclusive while the file is edited. - fn file_lock(&self, exclusive: bool) -> Option { + /// The lock's path: one for every build, since they edit one file. + pub(super) fn lock_path(&self) -> PathBuf { let inbox = self.config.inbox(); - std::fs::create_dir_all(&inbox).ok()?; - let _ = platform::set_mode(&inbox, 0o700); + inbox.parent().unwrap_or(&inbox).join(".hooks.lock") + } + + /// The lock over `hooks.json` across processes: shared while a waiting + /// hook claims, exclusive while the file is edited. + fn file_lock(&self, exclusive: bool) -> Option { + let path = self.lock_path(); + let dir = path.parent()?; + std::fs::create_dir_all(dir).ok()?; + let _ = platform::set_mode(dir, 0o700); use platform::OpenOptionsExt; let file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(false) .mode(0o600) - .open(inbox.join(".hooks.lock")) + .open(&path) .ok()?; let taken = if exclusive { fs2::FileExt::lock_exclusive(&file) @@ -244,6 +251,13 @@ impl CursorSettings { .lock .lock() .unwrap_or_else(|poison| poison.into_inner()); + if !enable + && self + .read_object() + .is_ok_and(|object| object.get("hooks").is_none()) + { + return Ok(()); + } let _claims = self.file_lock(true).ok_or( "Agent Relay could not lock its Cursor hooks, so ~/.cursor/hooks.json was left unchanged", )?; diff --git a/crates/probe/src/relay/cursor_cli/tests.rs b/crates/probe/src/relay/cursor_cli/tests.rs index 6c792a0898..793ef4d06a 100644 --- a/crates/probe/src/relay/cursor_cli/tests.rs +++ b/crates/probe/src/relay/cursor_cli/tests.rs @@ -743,9 +743,9 @@ fn a_setting_whose_lock_cannot_be_taken_is_left_as_it_is() { let settings = settings(&config); settings.set(SETTING, &json!(true)).unwrap().unwrap(); let on = std::fs::read(config.hooks_file()).unwrap(); - // The inbox the lock lives in cannot be made. - std::fs::remove_dir_all(config.inbox()).unwrap(); - std::fs::write(config.inbox(), "").unwrap(); + // The lock cannot be opened. + std::fs::remove_file(settings.lock_path()).unwrap(); + std::fs::create_dir(settings.lock_path()).unwrap(); assert_eq!( settings.set(SETTING, &json!(false)), Some(Err( @@ -942,3 +942,20 @@ fn live_cursor_session_gets_its_messages_once() { } assert!(pending(&config, &session).is_empty()); } + +#[test] +fn a_hook_with_nothing_to_hand_over_lets_the_prompt_go_on() { + let prompt = json!({"hook_event_name": "beforeSubmitPrompt"}); + assert_eq!(no_answer(&prompt), json!({"continue": true})); + assert_eq!(no_answer(&json!({"hook_event_name": "stop"})), json!({})); +} + +#[test] +fn every_build_edits_hooks_under_one_lock_and_turning_off_makes_none() { + let temp = tempfile::tempdir().unwrap(); + let config = CursorConfig::from_env(&|_| None, temp.path()); + let dev = config.clone().development(true); + assert_eq!(settings(&config).lock_path(), settings(&dev).lock_path()); + assert_eq!(settings(&config).set(SETTING, &json!(false)), Some(Ok(()))); + assert!(!settings(&config).lock_path().exists()); +} diff --git a/crates/probe/src/relay/cursor_cli/wait.rs b/crates/probe/src/relay/cursor_cli/wait.rs index 3b41fc4a56..a9e0ae5146 100644 --- a/crates/probe/src/relay/cursor_cli/wait.rs +++ b/crates/probe/src/relay/cursor_cli/wait.rs @@ -160,8 +160,15 @@ const TURN_VERSION: u32 = 1; struct CurrentTurn { version: u32, generation: String, + /// 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")] + earlier: Vec, } +/// How many earlier turns `CurrentTurn` keeps. +const EARLIER_TURNS: usize = 8; + fn turn_file(config: &CursorConfig, session: &str) -> PathBuf { dir(config).join(format!("{session}.turn")) } @@ -170,9 +177,13 @@ fn turn_file(config: &CursorConfig, session: &str) -> PathBuf { /// unreadable, corrupt or of another version: then no hook waits after a /// turn or claims for one until the next turn starts and writes it again. fn current_turn(config: &CursorConfig, session: &str) -> Option { + read_turn(config, session).map(|turn| turn.generation) +} + +fn read_turn(config: &CursorConfig, session: &str) -> Option { let turn: CurrentTurn = serde_json::from_slice(&std::fs::read(turn_file(config, session)).ok()?).ok()?; - (turn.version == TURN_VERSION).then_some(turn.generation) + (turn.version == TURN_VERSION).then_some(turn) } /// Names `generation`, when there is one, the turn that just started. @@ -180,10 +191,19 @@ fn current_turn(config: &CursorConfig, session: &str) -> Option { /// 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); + let mut earlier = Vec::new(); + if let Some(turn) = read_turn(config, session) { + earlier = turn.earlier; + if Some(turn.generation.as_str()) != generation { + earlier.push(turn.generation); + } + earlier.drain(..earlier.len().saturating_sub(EARLIER_TURNS)); + } let written = generation.filter(|generation| { serde_json::to_vec(&CurrentTurn { version: TURN_VERSION, generation: generation.to_string(), + earlier: earlier.clone(), }) .is_ok_and(|bytes| replace(config, &path, &bytes)) }); @@ -205,9 +225,9 @@ fn nonce(pid: i32) -> String { /// Notes a turn starting (`beforeSubmitPrompt`) as the conversation's /// current one, and retires the conversation's waiting hook unless both it /// and `payload`'s hook belong to the current turn: Cursor would drop its -/// follow-up once a newer turn owns the chat, and a hook of another turn is -/// either that or a late one. Every run of Agent Relay's hook calls this -/// first. It needs no proof of its caller: retiring and forgetting the turn +/// follow-up once a newer turn owns the chat, and a hook of an unknown turn +/// may be that. Only a hook of a turn recorded as earlier leaves it waiting: +/// that one is late. Every run of Agent Relay's hook calls this first. It needs no proof of its caller: retiring and forgetting the turn /// only leave the conversation's messages for its next hooks. pub fn turn_seen(config: &CursorConfig, payload: &Value) { let Some(session) = payload_session(payload) else { @@ -224,9 +244,13 @@ pub fn turn_seen(config: &CursorConfig, payload: &Value) { } else { current_turn(config, session) }; + let late = generation.is_some_and(|generation| { + read_turn(config, session) + .is_some_and(|turn| turn.earlier.iter().any(|earlier| earlier == generation)) + }); let retired = read(config, session).is_some_and(|waiter| { Some(waiter.generation.as_str()) != current.as_deref() - || Some(waiter.generation.as_str()) != generation + || (!late && Some(waiter.generation.as_str()) != generation) }); if retired { let _ = std::fs::remove_file(waiter_file(config, session)); diff --git a/crates/probe/src/relay/cursor_cli/wait_tests.rs b/crates/probe/src/relay/cursor_cli/wait_tests.rs index 3c23b043de..fa5c851452 100644 --- a/crates/probe/src/relay/cursor_cli/wait_tests.rs +++ b/crates/probe/src/relay/cursor_cli/wait_tests.rs @@ -461,6 +461,57 @@ fn a_stop_hook_of_an_older_turn_run_late_neither_claims_nor_waits() { ); } +#[test] +fn a_late_hook_of_an_older_turn_leaves_the_current_turns_hook_waiting() { + let (_temp, config, table) = editor(); + let flags = Flags::new(); + start(&config, "gen-2"); + std::thread::scope(|scope| { + let hook = scope.spawn(|| park(&config, &table, &flags, HOOK, &stop("gen-2"), LONG, true)); + eventually("the hook waits", || parked(&config)); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK + 1, + &turn("postToolUse", "gen-1"), + None + ), + (false, vec![]) + ); + assert!(waiting(&config)); + // It still wakes the chat. + message(&config, &table, "m-1", "ping"); + assert_eq!( + hook.join().unwrap(), + (Ended::Answered, vec![json!({"followup_message": "ping"})]) + ); + }); +} + +#[test] +fn a_prompt_records_a_new_conversation_and_hands_nothing_over() { + let (_temp, config, table) = world(); + table.insert(HOOK, shell(HOST_PID, &[])); + let flags = Flags::new(); + place(&config, "m-1", "ping"); + assert!(super::super::editor::live(&config, &table, uid(), SESSION).is_none()); + assert_eq!( + run( + &config, + &table, + &flags, + HOOK, + &turn("beforeSubmitPrompt", "gen-1"), + None + ), + (false, vec![]) + ); + assert!(super::super::editor::live(&config, &table, uid(), SESSION).is_some()); + assert_eq!(staged(&config), 1); +} + #[test] fn an_unknown_current_turn_takes_nothing_until_the_next_turn_starts() { let cases: [(&str, fn(&CursorConfig)); 6] = [ diff --git a/crates/probe/src/relay/hook_agents.rs b/crates/probe/src/relay/hook_agents.rs index 95c9fcc0e8..aaa56d4703 100644 --- a/crates/probe/src/relay/hook_agents.rs +++ b/crates/probe/src/relay/hook_agents.rs @@ -38,8 +38,10 @@ pub fn is_copilot_process(table: &dyn ProcessTable, pid: i32) -> bool { /// Whether `pid` is an agent other than Claude Code that runs hooks /// written for Claude: Cursor CLI and Copilot CLI read Claude's /// `settings.json` hooks, and Devin and Codex run Claude-format hooks of -/// their own. A hook found under one of these belongs to it, even when a -/// Claude Code session further up started it. +/// their own. The Cursor editor's extension host counts too: it runs the +/// hooks of `~/.cursor/hooks.json`, so one under it is never Claude's. A hook +/// found under one of these belongs to it, even when a Claude Code session +/// further up started it. pub fn is_other_agent(table: &dyn ProcessTable, pid: i32) -> bool { crate::relay::cursor_cli::is_cursor_process(table, pid) || crate::relay::cursor_cli::editor::is_editor_host(table, pid) diff --git a/crates/probe/src/relay/local/nearest.rs b/crates/probe/src/relay/local/nearest.rs index b49899486b..5a1d9ddc4e 100644 --- a/crates/probe/src/relay/local/nearest.rs +++ b/crates/probe/src/relay/local/nearest.rs @@ -37,7 +37,8 @@ impl LocalSessions { /// Whether the nearest agent process of this user's above `pid` (itself /// included) is OpenCode, rather than a Claude Code session, a trusted - /// Codex, a Grok leader, Muse or Devin. + /// Codex, a Grok leader, Muse, Devin, `cursor-agent` or a Cursor editor + /// extension host. pub(super) fn opencode_is_nearest(&self, pid: i32, claude: &claude::Registry) -> bool { let table = &*self.table; let mut current = pid; diff --git a/crates/probe/src/relay/local/plugins.rs b/crates/probe/src/relay/local/plugins.rs index 06e73d585e..05cf5b1e40 100644 --- a/crates/probe/src/relay/local/plugins.rs +++ b/crates/probe/src/relay/local/plugins.rs @@ -121,7 +121,13 @@ pub fn plugins_with_options( let cursor_settings = cursor_settings.clone(); let _ = std::thread::Builder::new() .name("cursor-hooks".into()) - .spawn(move || cursor_settings.refresh()); + .spawn(move || { + if let Err(error) = cursor_settings.refresh() { + crate::relay::diagnostics::write(format_args!( + "cursor: hooks refresh failed: {error}" + )); + } + }); } // OpenCode processes started from now on load this build's plugin. A // copy that cannot be rewritten keeps working as the older version. diff --git a/docs/probe-move.md b/docs/probe-move.md index 690895246a..0a6bde7e52 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -357,7 +357,7 @@ own, named `--`) and, per tool, through `settings.set`: | OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | | Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | | Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | -| Cursor | `cursor_direct_delivery` | hooks in `~/.cursor/hooks.json` | +| Cursor | `cursor_direct_delivery` | hooks in `~/.cursor/hooks.json` (the editor route needs a core newer than 15.0.7) | | Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | `auto_activate` alone never puts an open Pi or OpenCode session on the relay diff --git a/docs/quickstart.md b/docs/quickstart.md index 6b44c2ec87..e5545b3dc2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -142,7 +142,7 @@ relay init --hosted --tools all relay init --url https://relay.example.com:8443 --key --tools none ``` -`--tools` takes `claude`, `codex`, `opencode`, `grok`, `pi`, `devin`, `all`, or +`--tools` takes `claude`, `codex`, `opencode`, `grok`, `pi`, `devin`, `cursor`, `all`, or `none`. `--scope` is `project` or `global`. To remove what `relay init` added, and nothing else: diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index bdeb611b61..7e5dbd5539 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -1793,6 +1793,19 @@ describe('relay init message injection', () => { expect(logs.join('\n')).toContain('messages are held for your approval unless it is "accept"'); }); + it('reports a core refusing Cursor delivery and leaves it off', async () => { + installedProbe(); + const core = fakeCore({ + running: true, + settings: { auto_activate: true, cursor_direct_delivery: false }, + refuse: ['cursor_direct_delivery'], + }); + const { run, logs } = setup(core.deps); + await run('--tools', 'cursor'); + expect(core.settings.cursor_direct_delivery).toBe(false); + expect(logs.join('\n')).toContain('cursor_direct_delivery is not available'); + }); + it('turns on Cursor delivery and turns it off again on --uninstall', async () => { installedProbe(); const core = fakeCore({ running: true, settings: { auto_activate: true } }); From b3c86f000a69314d0dd4ed3b65c1601e751039cb Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 12:13:33 -0800 Subject: [PATCH 4/6] docs(probe): format the per-tool table Co-Authored-By: Claude Opus 5.5 --- docs/probe-move.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/probe-move.md b/docs/probe-move.md index 0a6bde7e52..d647eb2ab5 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -357,7 +357,7 @@ own, named `--`) and, per tool, through `settings.set`: | OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | | Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | | Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | -| Cursor | `cursor_direct_delivery` | hooks in `~/.cursor/hooks.json` (the editor route needs a core newer than 15.0.7) | +| Cursor | `cursor_direct_delivery` | hooks in `~/.cursor/hooks.json` (the editor route needs a core newer than 15.0.7) | | Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | `auto_activate` alone never puts an open Pi or OpenCode session on the relay From da1641fcbe03606dab5ea52a9b2e49cee6b16b98 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 14:04:04 -0800 Subject: [PATCH 5/6] fix(probe): never block Cursor discovery on a record lock Stale cleanup runs in discovery and a claim can hold the record lock through a signature check, so cleanup now skips a held lock and retries next time. Co-Authored-By: Claude Opus 5.5 --- crates/probe/src/relay/cli.rs | 5 +-- crates/probe/src/relay/cursor_cli/editor.rs | 34 +++++++++++++------ .../src/relay/cursor_cli/editor_tests.rs | 19 +++++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/crates/probe/src/relay/cli.rs b/crates/probe/src/relay/cli.rs index 315b0314d1..de49b42503 100644 --- a/crates/probe/src/relay/cli.rs +++ b/crates/probe/src/relay/cli.rs @@ -430,8 +430,9 @@ pub fn devin_hook(development: bool) -> Result<()> { /// completes. With `--wait` (the `stop` entry) an editor conversation's /// completed turn with nothing pending 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 prints -/// `{}` and exits 0, so a hook can never break the session it runs in. +/// 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 +/// `beforeSubmitPrompt`), so a hook can never break the session it runs in. /// `--dev` selects Agent Relay Dev's inbox. pub fn cursor_hook(development: bool, wait: bool) -> Result<()> { use crate::relay::cursor_cli::{wait as waiting, CursorConfig, CursorSettings}; diff --git a/crates/probe/src/relay/cursor_cli/editor.rs b/crates/probe/src/relay/cursor_cli/editor.rs index 717e47d1c2..bf9747d500 100644 --- a/crates/probe/src/relay/cursor_cli/editor.rs +++ b/crates/probe/src/relay/cursor_cli/editor.rs @@ -235,20 +235,25 @@ pub(super) fn claim( .unwrap_or(false) } -/// Runs `run` under `session_id`'s record lock. `None` when it cannot be -/// taken. -fn locked(config: &CursorConfig, session_id: &str, run: impl FnOnce() -> T) -> Option { +/// `session_id`'s record lock file, not yet locked. +pub(super) fn lock_file(config: &CursorConfig, session_id: &str) -> Option { let dir = records(config); std::fs::create_dir_all(&dir).ok()?; let _ = platform::set_mode(&dir, 0o700); use platform::OpenOptionsExt; - let lock = std::fs::OpenOptions::new() + std::fs::OpenOptions::new() .write(true) .create(true) .truncate(false) .mode(0o600) .open(dir.join(format!(".{session_id}.lock"))) - .ok()?; + .ok() +} + +/// Runs `run` under `session_id`'s record lock. `None` when it cannot be +/// taken. +fn locked(config: &CursorConfig, session_id: &str, run: impl FnOnce() -> T) -> Option { + let lock = lock_file(config, session_id)?; fs2::FileExt::lock_exclusive(&lock).ok()?; let result = run(); let _ = fs2::FileExt::unlock(&lock); @@ -257,14 +262,21 @@ fn locked(config: &CursorConfig, session_id: &str, run: impl FnOnce() -> T) - /// Removes `session_id`'s record if it is still `stale`: under the record /// lock, so a window that claimed the conversation since `stale` was read -/// keeps its own. +/// keeps its own. Discovery calls this and a claim can hold the lock through +/// a signature check, so it never waits: when the lock is held the record +/// stays for the next discovery. pub(super) fn remove_stale(config: &CursorConfig, session_id: &str, stale: &Record) { let path = records(config).join(format!("{session_id}.json")); - let _ = locked(config, session_id, || { - if read(&path).as_ref() == Some(stale) { - let _ = std::fs::remove_file(&path); - } - }); + let Some(lock) = lock_file(config, session_id) else { + return; + }; + if fs2::FileExt::try_lock_exclusive(&lock).is_err() { + return; + } + if read(&path).as_ref() == Some(stale) { + let _ = std::fs::remove_file(&path); + } + let _ = fs2::FileExt::unlock(&lock); } /// How the processes from `pid` up to (not including) `host` vouch for diff --git a/crates/probe/src/relay/cursor_cli/editor_tests.rs b/crates/probe/src/relay/cursor_cli/editor_tests.rs index a60fa6c6f0..9122d8b582 100644 --- a/crates/probe/src/relay/cursor_cli/editor_tests.rs +++ b/crates/probe/src/relay/cursor_cli/editor_tests.rs @@ -661,6 +661,25 @@ fn removing_a_stale_record_keeps_a_window_that_claimed_the_conversation_since() assert!(!path.exists()); } +#[test] +fn removing_a_stale_record_never_waits_for_a_claim_holding_the_lock() { + let (_temp, config, table) = world(); + let payload = hook_payload("postToolUse", SESSION); + record_editor_hook(&config, &table, HOST_PID, HOST_PID, &payload); + let path = config + .home + .join(".agentworkforce/desktop/cursor/editor") + .join(format!("{SESSION}.json")); + let stale = read(&path).unwrap(); + let held = lock_file(&config, SESSION).unwrap(); + fs2::FileExt::lock_exclusive(&held).unwrap(); + remove_stale(&config, SESSION, &stale); + assert_eq!(read(&path), Some(stale.clone())); + fs2::FileExt::unlock(&held).unwrap(); + remove_stale(&config, SESSION, &stale); + assert!(!path.exists()); +} + #[test] fn a_blank_first_workspace_root_is_skipped() { assert_eq!( From d91df959ed6886ee961dc005b6df46487eafb454 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sun, 1 Nov 2026 14:18:18 -0800 Subject: [PATCH 6/6] test(mcp): stop the autoname test calling live Cast The real agent client reached cast.agentrelay.com during tool calls, so the test timed out whenever Cast was slow. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/agent-relay-mcp.autoname.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/cli/agent-relay-mcp.autoname.test.ts b/packages/cli/src/cli/agent-relay-mcp.autoname.test.ts index b7e08f9bc6..8ba8ac77d4 100644 --- a/packages/cli/src/cli/agent-relay-mcp.autoname.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.autoname.test.ts @@ -37,6 +37,9 @@ vi.mock('@agent-relay/sdk', async (importOriginal) => ({ createWorkspaceClient: () => ({ agents: { registerOrRotate: relay.registerOrRotate, list: relay.list }, }), + createAgentClient: () => { + throw new Error('no agent client in tests'); + }, createRealtimeClient: () => { throw new Error('no realtime in tests'); },