diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index e1cf1e40b9a8..652f75f0de51 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -1903,7 +1903,23 @@ fn emit_hook_command(quoted_binary: &str, provider: &str, event: &str) -> String ) } +/// The installed hook command. It runs `$CMUX_TUI_HOOK`, which every cmux-tui +/// terminal exports. An agent inside tmux may have been started by a tmux +/// server that never ran in a cmux-tui terminal, so without that variable a +/// tmux pane falls back to the installed helper, which routes the event to the +/// cmux-tui terminal attached to the pane's tmux session. Anywhere else the +/// command stays a process-free no-op. fn hook_command(provider: &str, event: &str) -> String { + format!( + "h=${{CMUX_TUI_HOOK:-${{TMUX:+${{XDG_DATA_HOME:-$HOME/.local/share}}/cmux-tui/bin/cmux-tui-hook}}}};\"${{h:-:}}\" {} {} 2>/dev/null||:;echo {{}};#{COMMAND_MARKER}", + shell_quote(provider), + shell_quote(event), + ) +} + +/// The command shape before the tmux fallback. Its codex trust hashes stay +/// cmux-owned so an upgrade replaces them instead of leaving them behind. +fn legacy_hook_command(provider: &str, event: &str) -> String { format!( "\"${{CMUX_TUI_HOOK:-:}}\" {} {} 2>/dev/null||:;echo {{}};#{COMMAND_MARKER}", shell_quote(provider), @@ -2093,16 +2109,21 @@ fn codex_expected_trust_entries( Ok(entries) } -/// Every trust hash the current installer shape can produce. Entries carrying +/// Every trust hash the current and previous installer shapes can produce. Entries carrying /// one of these hashes are cmux-owned regardless of their positional key. fn codex_owned_trust_hashes() -> anyhow::Result> { CODEX_EVENTS .iter() .map(|event| { let label = codex_event_state_label(event)?; - Ok(codex_trust_hash(label, &hook_command("codex", event), codex_hook_timeout(event))) + let timeout = codex_hook_timeout(event); + Ok([ + codex_trust_hash(label, &hook_command("codex", event), timeout), + codex_trust_hash(label, &legacy_hook_command("codex", event), timeout), + ]) }) - .collect() + .collect::>>() + .map(|pairs| pairs.into_iter().flatten().collect()) } /// Dotfile managers commonly symlink `config.toml`; the atomic rename must @@ -2807,13 +2828,47 @@ mod tests { assert!(!text.contains(COMMAND_MARKER)); } - /// Trust hashes verified against the real codex 0.150.1 binary: with these + /// Trust hashes of the current `hook_command`, from the same identity hash + /// that reproduces `LEGACY_CODEX_TRUSTED_HASHES` below. + const CODEX_TRUSTED_HASHES: &[(&str, &str)] = &[ + ( + "session_start", + "sha256:62dec7fda2eedda09e521ed25f5a3e56fdf259e2cf6e09c61caf6fde04cf9169", + ), + ( + "user_prompt_submit", + "sha256:11c9dc25e1d294a6f3c33e6c03354c7e032250357e143879ac720a392cf632b9", + ), + ("stop", "sha256:c44b06979e220fd6665d250bb2cc470787cc4b06568e62b6cd8004577cdd124a"), + ( + "permission_request", + "sha256:6a6d12a917dfc12fdfc3e0796f4c5f43d31db0a9dd1f7372cce0f6635cd32b24", + ), + ("pre_tool_use", "sha256:73db9083c29d7b48384ab6e3684e0ab49f482f9d11cc07f6c7d2584f55175a34"), + ( + "post_tool_use", + "sha256:7eae35124685878835e8f7a4bc73214600f6732c8bddafc27370e4e89757d1a4", + ), + ("pre_compact", "sha256:e957b79dd72144e1e738feeeaade51816e2ae07fcaa31c9638fa40b895b2d580"), + ("post_compact", "sha256:adbb48bf6be51c36f594b09dc5b9b008d2de4b7e72bf20c7e897459beb295d6c"), + ( + "subagent_start", + "sha256:25a7790bb05c595170f35ce823c0e64080c31ec43e5c65003875466ace055dbd", + ), + ( + "subagent_stop", + "sha256:46e1ebc2d41d01b8f4c7ec6ee657e2cfc66ed0b0531fef08cfe52f7fd8f1a479", + ), + ("session_end", "sha256:b8231b7c25e8a4c9ecfbfa026269958f32763c359db6b16d4c7579416f5f3097"), + ]; + + /// Trust hashes of `legacy_hook_command`, verified against the real codex 0.150.1 binary: with these /// exact `hooks.state` values in `config.toml`, codex executes the installed /// hooks.json commands; without them it parses hooks.json (it even warns /// about clamping the SessionEnd timeout) and silently skips every handler, /// so codex sessions never reach the cmux-tui agents view /// (https://github.com/manaflow-ai/cmux/issues/11040). - const CODEX_TRUSTED_HASHES: &[(&str, &str)] = &[ + const LEGACY_CODEX_TRUSTED_HASHES: &[(&str, &str)] = &[ ( "session_start", "sha256:397d7ce9e0c6367e34771a4293777ff95415b595bf77e2aa420425adc75d70ae", @@ -2881,6 +2936,20 @@ mod tests { assert_eq!(state.len(), CODEX_EVENTS.len()); } + #[test] + fn codex_trust_hash_reproduces_the_hashes_codex_verified() { + let owned = codex_owned_trust_hashes().unwrap(); + for (event, (label, hash)) in CODEX_EVENTS.iter().zip(LEGACY_CODEX_TRUSTED_HASHES) { + let legacy = codex_trust_hash( + label, + &legacy_hook_command("codex", event), + codex_hook_timeout(event), + ); + assert_eq!(legacy, *hash, "{event}"); + assert!(owned.contains(*hash), "{event}: an upgrade must replace the old entry"); + } + } + #[test] fn codex_session_end_hook_timeout_stays_within_the_codex_cap() { let root = tempfile::tempdir().unwrap(); @@ -3660,7 +3729,7 @@ esac serde_json::from_slice(&fs::read(context.home.join(".codex/hooks.json")).unwrap()) .unwrap(); let command = root["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap(); - assert!(command.len() <= 90, "hook command is {} bytes: {command}", command.len()); + assert!(command.len() <= 170, "hook command is {} bytes: {command}", command.len()); assert!(!command.contains("CMUX_TUI_SOCKET")); assert!(!hook_command("claude", "Stop").contains("GROK_HOOK_EVENT")); @@ -3668,6 +3737,7 @@ esac .args(["-c", command]) .env("CMUX_TUI_SOCKET", "/tmp/cmux-test.sock") .env_remove("CMUX_TUI_HOOK") + .env_remove("TMUX") .env("CAPTURE", &capture) .output() .unwrap(); @@ -3684,16 +3754,32 @@ esac .unwrap(); assert!(output.status.success()); assert_eq!(output.stdout, b"{}\n"); - assert_eq!(fs::read_to_string(capture).unwrap(), "codex Stop\n"); + assert_eq!(fs::read_to_string(&capture).unwrap(), "codex Stop\n"); + fs::remove_file(&capture).unwrap(); + + // A tmux pane without the session's variables falls back to the + // installed helper, which routes through the attached tmux client. + let output = Command::new("/bin/sh") + .args(["-c", command]) + .env_remove("CMUX_TUI_SOCKET") + .env_remove("CMUX_TUI_HOOK") + .env("TMUX", "/tmp/tmux-test/default,1,0") + .env("XDG_DATA_HOME", &context.data_home) + .env("CAPTURE", &capture) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"{}\n"); + assert_eq!(fs::read_to_string(&capture).unwrap(), "codex Stop\n"); } #[test] - fn every_command_hook_fits_in_one_hundred_bytes() { + fn every_command_hook_fits_in_two_hundred_bytes() { for provider in PROVIDERS { for event in provider.events { let command = hook_command(provider.id, event); assert!( - command.len() <= 100, + command.len() <= 200, "{} {event} hook command is {} bytes: {command}", provider.id, command.len() diff --git a/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs b/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs index 05071f951ee1..5c81e85b62ff 100644 --- a/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs +++ b/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs @@ -84,14 +84,13 @@ fn run(args: Args, exe_prefix: &[&str]) -> anyhow::Result<()> { drain_native_payload()?; return Ok(()); } - let socket = match env::var_os("CMUX_TUI_SOCKET").filter(|value| !value.is_empty()) { - Some(socket) => PathBuf::from(socket), + let (socket, terminal) = match session_route() { + Some(route) => route, None => { drain_native_payload()?; return Ok(()); } }; - let terminal = env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|value| !value.is_empty()); let native = read_native_payload(io::stdin().lock())?; let ingress = cmux_tui_core::agent_hook_journal_ingress( &args.source, @@ -111,6 +110,158 @@ fn run(args: Args, exe_prefix: &[&str]) -> anyhow::Result<()> { } } +/// The session socket and terminal that receive this event: the terminal's +/// own `CMUX_TUI_*` values, or, for an agent in a tmux session started +/// outside cmux-tui (which has none), the cmux-tui terminal attached to the +/// pane's tmux session. +fn session_route() -> Option<(PathBuf, Option)> { + if let Some(socket) = env::var_os("CMUX_TUI_SOCKET").filter(|value| !value.is_empty()) { + let terminal = env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|value| !value.is_empty()); + return Some((PathBuf::from(socket), terminal)); + } + let route = tmux_route::attached_terminal()?; + Some((route.socket, Some(route.terminal))) +} + +/// Finds the cmux-tui terminal whose tmux client shows the hook's pane. +/// +/// An agent in a tmux session started outside cmux-tui (by a launcher over +/// SSH, say) has no `CMUX_TUI_*` variables, but the `tmux attach` running in +/// a cmux-tui terminal does. Clients of the pane's session, or of a session +/// grouped with it, are candidates: one whose current window holds the pane +/// first, then the most recently active. The first whose environment names a +/// session socket and terminal wins. Client environments are read from +/// `/proc`, so this is Linux only. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod tmux_route { + use std::collections::HashMap; + use std::path::PathBuf; + #[cfg(target_os = "linux")] + use std::time::{Duration, Instant}; + + /// Bound on both tmux queries together. It comes out of the provider's + /// hook budget, and codex kills SessionEnd hooks at 3s. + #[cfg(target_os = "linux")] + const TMUX_BUDGET: Duration = Duration::from_millis(500); + + #[derive(Debug, PartialEq, Eq)] + pub(super) struct Route { + pub(super) socket: PathBuf, + pub(super) terminal: String, + } + + #[cfg(target_os = "linux")] + pub(super) fn attached_terminal() -> Option { + std::env::var_os("TMUX").filter(|value| !value.is_empty())?; + let deadline = Instant::now() + TMUX_BUDGET; + let pane = std::env::var("TMUX_PANE").ok().filter(|value| !value.is_empty()); + let mut display = vec!["display-message", "-p"]; + if let Some(pane) = pane.as_deref() { + display.extend(["-t", pane]); + } + display.push(PANE_FORMAT); + let pane_line = run_tmux(&display, deadline)?; + let clients = run_tmux(&["list-clients", "-F", CLIENT_FORMAT], deadline)?; + select(&pane_line, &clients, proc_environ) + } + + #[cfg(not(target_os = "linux"))] + pub(super) fn attached_terminal() -> Option { + None + } + + pub(super) const PANE_FORMAT: &str = "#{session_id}\t#{session_group}\t#{window_id}"; + pub(super) const CLIENT_FORMAT: &str = + "#{client_pid}\t#{client_activity}\t#{session_id}\t#{session_group}\t#{window_id}"; + + /// Picks the route from `display-message` output for the pane and + /// `list-clients` output, reading each candidate's environment. + pub(super) fn select( + pane_line: &str, + clients: &str, + environ: impl Fn(u32) -> Option>, + ) -> Option { + let pane_line = pane_line.strip_suffix('\n').unwrap_or(pane_line); + let pane: Vec<&str> = pane_line.split('\t').collect(); + let [session, group, window] = pane[..] else { return None }; + if session.is_empty() { + return None; + } + let mut candidates: Vec<(bool, i64, u32)> = clients + .lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split('\t').collect(); + let [pid, activity, client_session, client_group, client_window] = fields[..] + else { + return None; + }; + let same_session = + client_session == session || (!group.is_empty() && client_group == group); + let pid = pid.parse::().ok().filter(|pid| *pid > 1)?; + same_session.then(|| { + let shows_pane = !window.is_empty() && client_window == window; + (shows_pane, activity.parse().unwrap_or(0), pid) + }) + }) + .collect(); + candidates.sort_by(|left, right| right.0.cmp(&left.0).then(right.1.cmp(&left.1))); + candidates.into_iter().find_map(|(_, _, pid)| { + let environment = environ(pid)?; + let value = |key: &str| environment.get(key).filter(|value| !value.is_empty()).cloned(); + Some(Route { + socket: value("CMUX_TUI_SOCKET")?.into(), + terminal: value("CMUX_TUI_TERMINAL_ID")?, + }) + }) + } + + /// A same-user process environment; other users' are unreadable. + #[cfg(target_os = "linux")] + fn proc_environ(pid: u32) -> Option> { + let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + Some( + data.split(|byte| *byte == 0) + .filter_map(|entry| { + let entry = std::str::from_utf8(entry).ok()?; + let (key, value) = entry.split_once('=')?; + (!key.is_empty()).then(|| (key.to_owned(), value.to_owned())) + }) + .collect(), + ) + } + + /// Runs tmux against the server named by `$TMUX`, killed at `deadline`. + #[cfg(target_os = "linux")] + fn run_tmux(args: &[&str], deadline: Instant) -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + + let mut child = Command::new("tmux") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + let mut output = String::new(); + child.stdout.take()?.read_to_string(&mut output).ok()?; + Some(output) + } +} + /// Outcome of the provider-facing wait for the detached child. #[derive(Debug, PartialEq, Eq)] enum Handoff { @@ -817,6 +968,49 @@ fn random_identifiers() -> anyhow::Result<(String, String)> { mod tests { use super::*; + fn cmux_environ(pid: u32) -> Option> { + let environment = |terminal: &str| { + Some( + [("CMUX_TUI_SOCKET", "/run/cmux.sock"), ("CMUX_TUI_TERMINAL_ID", terminal)] + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .into(), + ) + }; + match pid { + 10 => environment("term_other_window"), + 11 => environment("term_shows_pane"), + 12 => environment("term_recent"), + // A plain SSH client: no cmux-tui terminal. + 13 => Some([("SSH_TTY".to_owned(), "/dev/pts/3".to_owned())].into()), + _ => None, + } + } + + #[test] + fn tmux_route_prefers_the_client_showing_the_pane() { + let clients = "10\t500\t$1\t\t@2\n11\t100\t$1\t\t@1\n12\t900\t$7\t\t@9\n"; + let route = tmux_route::select("$1\t\t@1\n", clients, cmux_environ).unwrap(); + assert_eq!(route.terminal, "term_shows_pane"); + assert_eq!(route.socket, PathBuf::from("/run/cmux.sock")); + } + + #[test] + fn tmux_route_falls_back_to_the_most_recent_client_of_the_session_group() { + // Client 13 shows the pane but is a plain SSH client; 12 attaches a + // grouped session and is newer than 10. + let clients = "13\t999\t$1\tgrp\t@1\n10\t500\t$1\tgrp\t@2\n12\t900\t$7\tgrp\t@9\n"; + let route = tmux_route::select("$1\tgrp\t@1", clients, cmux_environ).unwrap(); + assert_eq!(route.terminal, "term_recent"); + } + + #[test] + fn tmux_route_ignores_other_sessions_and_non_cmux_clients() { + let clients = "12\t900\t$7\t\t@9\n13\t999\t$1\t\t@1\n"; + assert_eq!(tmux_route::select("$1\t\t@1", clients, cmux_environ), None); + assert_eq!(tmux_route::select("", clients, cmux_environ), None); + assert_eq!(tmux_route::select("$1\t\t@1", "garbage\n1\t0\t$1\t\t@1\n", cmux_environ), None); + } + #[test] fn codex_session_end_handoff_stays_below_the_codex_hook_cap() { assert!(handoff_wait("codex", "SessionEnd") < Duration::from_secs(3)); diff --git a/cmux-tui/crates/cmux-tui/src/claude_wrapper.rs b/cmux-tui/crates/cmux-tui/src/claude_wrapper.rs index 9dfab3636629..5cfea4708ace 100644 --- a/cmux-tui/crates/cmux-tui/src/claude_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/src/claude_wrapper.rs @@ -725,7 +725,7 @@ mod tests { fn claude_wrapper_hook_commands_match_the_installed_hooks_or_fall_back_to_emit() { let installed = session_hook_settings(None).unwrap(); let command = installed["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap(); - assert!(command.starts_with("\"${CMUX_TUI_HOOK:-:}\" 'claude' 'Stop'"), "{command}"); + assert!(command.contains("\"${h:-:}\" 'claude' 'Stop'"), "{command}"); assert_eq!(installed["hooks"]["Stop"][0]["hooks"][0]["async"], true); let emit = session_hook_settings(Some(Path::new("/opt/cmux tui/cmux-tui"))).unwrap(); diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 1cc8986b7470..00fa1dfa5d4a 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -3339,8 +3339,7 @@ impl Terminal { let Some(title) = self.title() else { return Vec::new(); }; - let title: String = - title.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); format!("\x1b]2;{title}\x1b\\").into_bytes() } diff --git a/cmux-tui/docs/agent-hooks.md b/cmux-tui/docs/agent-hooks.md index dc8d846b7ff4..fbc837b86f04 100644 --- a/cmux-tui/docs/agent-hooks.md +++ b/cmux-tui/docs/agent-hooks.md @@ -12,6 +12,23 @@ cmux agent hook install claude cmux agent hook status ``` +## Agents in a tmux session started elsewhere + +An agent can run in a tmux session whose server was started outside +cmux-tui, for example by a launcher over SSH, and be viewed by running +`tmux attach` in a cmux-tui terminal. Its panes have no `CMUX_TUI_HOOK`, so +the installed hook commands fall back to the installed `cmux-tui-hook` helper +whenever `TMUX` is set. On Linux the helper finds the tmux clients of the +pane's session (or a session grouped with it), prefers the one whose current +window shows the pane and then the most recently active one, and reads the +session socket and terminal id from that client's environment. The event goes +to that terminal, so its agent status appears where the session is attached. +Each event is routed on its own, so after the session moves to another +terminal the next event reports there. A pane that has the session's +variables, including one in a tmux server started from a cmux-tui terminal, +keeps using them. Agents started before the hooks were installed or updated +pick this up on their next start. + ## Claude Code without an install Claude Code started in a cmux-tui terminal gets the session's hooks even when