Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/rmux-api/src/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ pub struct NotificationCreateParams {
/// Optional body text.
#[serde(default)]
pub body: Option<String>,
/// Optional workspace id (cmux / agent routing).
#[serde(default)]
pub workspace_id: Option<u64>,
/// Optional pane id (cmux surface / agent routing).
#[serde(default)]
pub pane_id: Option<u64>,
}

/// Result of [`NOTIFICATION_CREATE`].
Expand Down
1 change: 1 addition & 0 deletions crates/rmux-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ thiserror = { workspace = true }
tokio = { workspace = true }
rmux-terminal = { path = "../rmux-terminal" }
rmux-api = { path = "../rmux-api" }
rmux-cli = { path = "../rmux-cli" }
rmux-config = { path = "../rmux-config" }
wry = { workspace = true }
arboard = "3"
Expand Down
15 changes: 13 additions & 2 deletions crates/rmux-app/src/api_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,21 @@ fn notification_create(app: &mut RmuxApp, params: Value) -> Result<Value, JsonRp
(Some(subtitle), None) => Some(subtitle),
(None, body) => body,
};
let id = app.notifications.add(params.title.clone(), body.clone(), None, None);
let id = app.notifications.add(
params.title.clone(),
body.clone(),
params.pane_id,
params.workspace_id,
);
app.publish_event(
"notification",
json!({ "id": id, "title": params.title, "body": body, "pane_id": null, "workspace_id": null }),
json!({
"id": id,
"title": params.title,
"body": body,
"pane_id": params.pane_id,
"workspace_id": params.workspace_id,
}),
);
Ok(json!({ "id": id }))
}
Expand Down
38 changes: 37 additions & 1 deletion crates/rmux-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,9 @@ fn attach_terminal(
cwd: Option<&std::path::Path>,
bg_opacity: f32,
) {
match TerminalPane::spawn_with_cwd(INITIAL_COLS, INITIAL_ROWS, font_size, cwd) {
let workspace_id = manager.active().id.0;
let env = agent_integration_env(workspace_id, pane_id.0);
match TerminalPane::spawn_with_env(INITIAL_COLS, INITIAL_ROWS, font_size, cwd, &env) {
Ok(mut terminal) => {
terminal.set_theme(rmux_terminal::TerminalTheme::default().named(named_theme));
terminal.set_bg_opacity(bg_opacity);
Expand All @@ -942,3 +944,37 @@ fn attach_terminal(
Err(e) => tracing::error!(pane_id = pane_id.0, "Failed to spawn terminal pane: {e}"),
}
}

/// Environment variables injected into every PTY so agent notify plugins
/// (OpenCode kdco-notify / cmux-compatible tools) can reach this workspace.
///
/// Sets both `RMUX_*` and `CMUX_*` aliases. Also ensures a `cmux` shim exists
/// under `~/.local/bin` so plugins that shell out to `cmux notify` succeed
/// instead of falling back to the external `alerter` binary on macOS.
fn agent_integration_env(workspace_id: u64, pane_id: u64) -> Vec<(String, String)> {
// Best-effort: install/update the cmux PATH shim once per process.
static SHIM_ONCE: std::sync::Once = std::sync::Once::new();
SHIM_ONCE.call_once(|| {
if let Some(dir) = rmux_cli::cmux_compat::ensure_local_shim() {
tracing::info!(path = %dir.join("cmux").display(), "ensured cmux notify shim");
} else {
tracing::debug!("could not install cmux notify shim (HOME/rmux-cli missing?)");
}
});

let socket = rmux_api::default_socket_path();
let socket_s = socket.display().to_string();
let ws = workspace_id.to_string();
let pane = pane_id.to_string();

vec![
("RMUX_WORKSPACE_ID".to_owned(), ws.clone()),
("RMUX_PANE_ID".to_owned(), pane.clone()),
("RMUX_SOCKET_PATH".to_owned(), socket_s.clone()),
// cmux-compatible aliases used by OpenCode notify / kdco plugins
("CMUX_WORKSPACE_ID".to_owned(), ws),
("CMUX_SURFACE_ID".to_owned(), pane),
("CMUX_SOCKET_PATH".to_owned(), socket_s),
("CMUX_SOCKET_MODE".to_owned(), "allowall".to_owned()),
]
}
16 changes: 15 additions & 1 deletion crates/rmux-app/src/ui/terminal_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,21 @@ impl TerminalPane {
font_size: f32,
cwd: Option<&Path>,
) -> Result<Self, PtyError> {
let mut backend = PtyBackend::spawn_with_cwd(cols, rows, cwd)?;
Self::spawn_with_env(cols, rows, font_size, cwd, &[])
}

/// Spawn a terminal and inject `extra_env` into the shell process.
///
/// Used to export `RMUX_*` / `CMUX_*` so agent notify plugins can reach
/// this pane via the socket API (cmux-compatible path).
pub fn spawn_with_env(
cols: u16,
rows: u16,
font_size: f32,
cwd: Option<&Path>,
extra_env: &[(String, String)],
) -> Result<Self, PtyError> {
let mut backend = PtyBackend::spawn_with_env(cols, rows, cwd, extra_env)?;
let state = TermState::new(cols, rows, 10_000);
let renderer = TerminalRenderer::new(font_size);
let input_mapper = InputMapper::new();
Expand Down
228 changes: 228 additions & 0 deletions crates/rmux-cli/src/cmux_compat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! cmux CLI compatibility for agent notify plugins (OpenCode kdco-notify, etc.).
//!
//! Those plugins prefer:
//! ```text
//! cmux notify --title … [--subtitle …] --body …
//! cmux set-status <key> <text>
//! cmux clear-status <key>
//! ```
//! when `CMUX_WORKSPACE_ID` (or socket mode) is set and `cmux` is on `PATH`.
//! Without this, macOS falls back to the external `alerter` binary.
//!
//! Invoked as `rmux-cli __cmux-compat …` or when the binary argv0 is `cmux`
//! (install symlink / PATH shim).

use std::env;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde_json::json;

use crate::socket;

/// Run a cmux-compatible command (`args` without the program name).
///
/// # Errors
///
/// Returns an error when arguments are invalid or the socket call fails.
pub fn run(socket_path: &Path, args: &[String]) -> Result<()> {
let mut it = args.iter().map(String::as_str);
let Some(cmd) = it.next() else {
bail!("cmux: missing command (expected notify, set-status, or clear-status)");
};

match cmd {
"notify" => run_notify(socket_path, &args[1..])?,
"set-status" => {
// cmux: set-status <key> <text…>
let key = it.next().unwrap_or("");
let text_parts: Vec<&str> = it.collect();
if text_parts.is_empty() {
bail!("cmux set-status: expected <key> <text>");
}
let text = if key.is_empty() {
text_parts.join(" ")
} else {
// Prefer the human-visible text; key is a session id for cmux.
text_parts.join(" ")
};
let workspace_id = workspace_id_from_env();
let params = json!({ "workspace_id": workspace_id, "status": text });
let _ = socket::call(socket_path, "sidebar.set_status", params)?;
}
"clear-status" => {
// cmux: clear-status <key>
let _key = it.next();
let workspace_id = workspace_id_from_env();
let params = json!({ "workspace_id": workspace_id });
let _ = socket::call(socket_path, "sidebar.clear_status", params)?;
}
// Soft-success for other cmux probes so plugins don't hard-fail.
other => {
eprintln!("rmux cmux-compat: ignoring unsupported command: {other}");
}
}
Ok(())
}

fn run_notify(socket_path: &Path, args: &[String]) -> Result<()> {
let mut title: Option<String> = None;
let mut subtitle: Option<String> = None;
let mut body: Option<String> = None;

let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--title" => {
i += 1;
title = args.get(i).cloned();
}
"--subtitle" => {
i += 1;
subtitle = args.get(i).cloned();
}
"--body" => {
i += 1;
body = args.get(i).cloned();
}
flag if flag.starts_with("--") => {
// Skip unknown flags and their optional values.
if args.get(i + 1).is_some_and(|v| !v.starts_with("--")) {
i += 1;
}
}
_ => {}
}
i += 1;
}

let title = title.context("cmux notify: --title is required")?;
let workspace_id = workspace_id_from_env();
let pane_id = env_u64("CMUX_SURFACE_ID").or_else(|| env_u64("RMUX_PANE_ID"));

let params = json!({
"title": title,
"subtitle": subtitle,
"body": body,
"workspace_id": workspace_id,
"pane_id": pane_id,
});
let _ = socket::call(socket_path, "notification.create", params)?;
Ok(())
}

fn workspace_id_from_env() -> Option<u64> {
env_u64("CMUX_WORKSPACE_ID").or_else(|| env_u64("RMUX_WORKSPACE_ID"))
}

fn env_u64(key: &str) -> Option<u64> {
env::var(key).ok().and_then(|s| s.parse().ok())
}

/// Resolve the socket for cmux-compat: CLI flag path is passed in; env
/// fallbacks honor both `CMUX_SOCKET_PATH` and `RMUX_SOCKET_PATH`.
pub fn effective_socket_path(flag: Option<PathBuf>) -> PathBuf {
if let Some(p) = flag {
return p;
}
if let Ok(p) = env::var("CMUX_SOCKET_PATH") {
let trimmed = p.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
socket::effective_socket_path(None)
}

/// Ensure `~/.local/bin/cmux` (or platform equivalent) is a shim to `rmux-cli
/// __cmux-compat`. Returns the directory containing the shim when successful.
///
/// Idempotent: rewrites the shim if the target path changed.
pub fn ensure_local_shim() -> Option<PathBuf> {
let bin_dir = local_bin_dir()?;
let rmux_cli = discover_rmux_cli()?;
let shim = bin_dir.join("cmux");

let body = format!(
"#!/bin/sh\n# rmux cmux shim — auto-managed; do not edit\nexec {} __cmux-compat \"$@\"\n",
shell_quote_path(&rmux_cli)
);

let needs_write = match std::fs::read_to_string(&shim) {
Ok(existing) => existing != body,
Err(_) => true,
};

if needs_write {
std::fs::create_dir_all(&bin_dir).ok()?;
std::fs::write(&shim, body).ok()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shim).ok()?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shim, perms).ok()?;
}
}

Some(bin_dir)
}

fn local_bin_dir() -> Option<PathBuf> {
// Prefer XDG/local conventions that login shells usually put on PATH.
if let Some(home) = env::var_os("HOME") {
return Some(PathBuf::from(home).join(".local").join("bin"));
}
None
}

fn discover_rmux_cli() -> Option<PathBuf> {
// 1. Same directory as the running binary (cargo run / install layout).
if let Ok(exe) = env::current_exe()
&& let Some(dir) = exe.parent()
{
let candidate = dir.join("rmux-cli");
if candidate.is_file() {
return Some(candidate);
}
// When this process *is* rmux-cli (shim target), use ourselves.
if exe.file_name().and_then(|s| s.to_str()) == Some("rmux-cli") {
return Some(exe);
}
}
// 2. PATH lookup.
env::var_os("PATH").and_then(|paths| {
env::split_paths(&paths).find_map(|dir| {
let candidate = dir.join("rmux-cli");
candidate.is_file().then_some(candidate)
})
})
}

fn shell_quote_path(path: &Path) -> String {
let s = path.display().to_string();
if s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c)) {
s
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn shell_quote_path_escapes_spaces() {
assert_eq!(shell_quote_path(Path::new("/tmp/rmux-cli")), "/tmp/rmux-cli");
let quoted = shell_quote_path(Path::new("/tmp/my cli"));
assert!(quoted.starts_with('\''), "{quoted}");
assert!(quoted.contains("my cli"), "{quoted}");
}

#[test]
fn env_u64_parses() {
// Pure parse helper path via missing keys.
assert!(env_u64("RMUX_TEST_NO_SUCH_ENV_VAR_XYZ").is_none());
}
}
8 changes: 8 additions & 0 deletions crates/rmux-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ pub enum Command {
Events(EventsCommand),
/// Invoke any socket method with raw JSON params (escape hatch)
Call(CallCommand),
/// cmux CLI compatibility for agent notify plugins (PATH shim target)
#[command(name = "__cmux-compat", hide = true)]
CmuxCompat {
/// cmux argv after the program name (`notify …`, `set-status …`, …)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Back-compat flat aliases from the Phase 3 CLI
#[command(flatten)]
Alias(AliasCommand),
Expand All @@ -83,6 +90,7 @@ pub fn run(command: Command, socket_path: &Path, opts: OutputOpts) -> Result<()>
Command::App(cmd) => app_cmd::run(cmd, socket_path, opts),
Command::Events(cmd) => events::run(cmd, socket_path, opts),
Command::Call(cmd) => call::run(cmd, socket_path, opts),
Command::CmuxCompat { args } => crate::cmux_compat::run(socket_path, &args),
Command::Alias(cmd) => aliases::run(cmd, socket_path, opts),
}
}
2 changes: 2 additions & 0 deletions crates/rmux-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
//!
//! - [`socket`] — socket path resolution and the blocking line-protocol client
//! - [`commands`] — hierarchical domain commands + back-compat aliases
//! - [`cmux_compat`] — cmux CLI shim (`notify` / `set-status` / `clear-status`)
//! - [`output`] — shared stdout formatting (`--json` / tables)
//! - [`util`] — escape interpretation and id extraction

pub mod cmux_compat;
pub mod commands;
pub mod output;
pub mod socket;
Expand Down
Loading
Loading