Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ 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

- `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

Expand Down
13 changes: 9 additions & 4 deletions crates/probe/src/probe/relay_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
104 changes: 66 additions & 38 deletions crates/probe/src/relay/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,52 +421,80 @@ 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 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};
let mut input = Vec::new();
let _ = std::io::stdin().take(4 << 20).read_to_end(&mut input);
let payload = serde_json::from_slice::<Value>(&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<waiting::Ended>| {
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 {
let output = crate::relay::cursor_cli::no_answer(&payload);
Comment thread
kjgbot marked this conversation as resolved.
Resolved
stdout.write_all(output.to_string().as_bytes())?;
stdout.write_all(b"\n")?;
}
Ok(())
}
Expand Down
Loading
Loading