diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fccf28..ab76aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Agent rows now carry `tmux_socket` and `tmux_session` on the wire** + (additive, optional). The reconciler backfills both from its multi-socket + pane scan each tick, and hook adapters send the pane's server socket path + from `$TMUX`, so pane ids are no longer ambiguous across tmux servers + (e.g. a dedicated `tmux -L amux` server vs the default one). Clients such + as amux join agents to workspaces by session name instead of guessing by + pane id. +- **Socket-aware liveness and dedup**: an agent tagged with a server socket + is only considered alive while *that* server has its pane, and two agents + on same-numbered panes of different servers are never collapsed as + duplicates. + ## [0.8.17] - 2026-07-08 ### Fixed diff --git a/PROTOCOL.md b/PROTOCOL.md index 51215e6..9152005 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -206,9 +206,16 @@ Tagged union. `type` field is the discriminant. { "kind": "claude_code" | "codex" | "gemini_cli" | "opencode" | "unknown", "session_id": "string", "pane": "string | null", + "tmux_socket": "string | absent", "cwd": "string | null" } ``` +`tmux_socket` (additive, optional): absolute path of the tmux server +socket `pane` belongs to — the first comma-separated field of `$TMUX` at +hook time. Pane ids are only unique per server, so this disambiguates +panes on non-default servers (e.g. a dedicated `tmux -L amux` server). +Adapters that predate the field simply never send it. + `level`: `"info" | "needs_input" | "needs_choice" | "warning" | "error"`. @@ -231,7 +238,8 @@ Same fields as stored in the registry: "kind": "claude_code", "session_id": "sess-abc", "pane": "%12", - "tmux_session": "main", + "tmux_socket": "amux", + "tmux_session": "amux-spike", "cwd": "/home/user/proj", "state": "working" | "idle" | "waiting_input" | "waiting_choice" | "error" | "stopped" | "starting", "last_prompt": "string | null", @@ -244,6 +252,13 @@ Same fields as stored in the registry: } ``` +`tmux_socket` and `tmux_session` are additive and optional. `tmux_socket` +is the short socket name (the socket file's basename, e.g. `default` or +`amux`) — from the adapter's `$TMUX` or backfilled by the daemon's +reconciler. `tmux_session` is backfilled by the reconciler's multi-socket +pane scan each tick; it is absent until the first tick after the agent's +pane is seen. + ## `HistoryEntry` schema (in `recent_prompts` responses) Prompt-history entries are a retained audit log, not the live agent row: diff --git a/crates/muxa-cli/src/attend.rs b/crates/muxa-cli/src/attend.rs index 8e65b4a..8b6c3cc 100644 --- a/crates/muxa-cli/src/attend.rs +++ b/crates/muxa-cli/src/attend.rs @@ -381,6 +381,7 @@ mod tests { fn pane(id: &str, session: &str, window: u32, idx: u32) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: session.into(), window_index: window.to_string(), @@ -401,6 +402,8 @@ mod tests { entered: OffsetDateTime, ) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: session.into(), surface: None, diff --git a/crates/muxa-cli/src/dashboard_tui.rs b/crates/muxa-cli/src/dashboard_tui.rs index fface10..e338fce 100644 --- a/crates/muxa-cli/src/dashboard_tui.rs +++ b/crates/muxa-cli/src/dashboard_tui.rs @@ -3339,6 +3339,8 @@ mod tests { at: OffsetDateTime, ) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::Codex, session_id: session.to_string(), surface: None, @@ -3368,6 +3370,7 @@ mod tests { fn fake_pane(pane_id: &str, session: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: pane_id.to_string(), session: session.to_string(), window_index: "1".into(), diff --git a/crates/muxa-cli/src/main.rs b/crates/muxa-cli/src/main.rs index 9cad4eb..fdd25bb 100644 --- a/crates/muxa-cli/src/main.rs +++ b/crates/muxa-cli/src/main.rs @@ -1460,6 +1460,8 @@ mod tests { fn agent(session_id: &str, pane: Option<&str>, state: AgentState, prompt: &str) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: session_id.into(), surface: None, @@ -1489,6 +1491,7 @@ mod tests { fn pane(id: &str, session: &str) -> muxa::tmux::PaneInfo { muxa::tmux::PaneInfo { + socket: None, pane_id: id.into(), session: session.into(), window_index: "12".into(), diff --git a/crates/muxa-cli/src/stats.rs b/crates/muxa-cli/src/stats.rs index 12f167d..11a3194 100644 --- a/crates/muxa-cli/src/stats.rs +++ b/crates/muxa-cli/src/stats.rs @@ -3133,6 +3133,8 @@ mod tests { fn live_agent(state: AgentState, state_entered_at: OffsetDateTime, cwd: Option<&str>) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::Codex, session_id: "agent-live".into(), surface: None, diff --git a/crates/muxa-cli/src/watch.rs b/crates/muxa-cli/src/watch.rs index 1d2ffbe..3e9fec1 100644 --- a/crates/muxa-cli/src/watch.rs +++ b/crates/muxa-cli/src/watch.rs @@ -5179,6 +5179,8 @@ mod tests { ) -> Agent { let now = OffsetDateTime::now_utc(); Agent { + tmux_socket: None, + tmux_session: None, kind, session_id: session.into(), surface: None, @@ -5276,6 +5278,7 @@ mod tests { fn fake_pane(pane: &str, session: &str, window: u32, pane_idx: u32, cmd: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: pane.into(), session: session.into(), window_index: window.to_string(), @@ -6328,6 +6331,7 @@ sort = ["state"] #[test] fn pane_display_resolves_against_cached_panes() { let panes = vec![PaneInfo { + socket: None, pane_id: "%42".into(), session: "main".into(), window_index: "1".into(), diff --git a/crates/muxa/benches/store_apply.rs b/crates/muxa/benches/store_apply.rs index e1ace86..c54f411 100644 --- a/crates/muxa/benches/store_apply.rs +++ b/crates/muxa/benches/store_apply.rs @@ -48,6 +48,8 @@ fn make_realistic_agent() -> Agent { let prompt = "x".repeat(4096); let response = "y".repeat(4096); Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "sess-bench-0001".into(), surface: None, @@ -133,6 +135,7 @@ async fn bench_one(n_subscribers: usize, iters: u64) -> (Duration, Duration, u64 // a Started event to seed identity fields and reach Idle, then warm // the Working/Idle ping-pong. let id = AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "sess-bench-0001".into(), surface: None, diff --git a/crates/muxa/src/adapters/claude.rs b/crates/muxa/src/adapters/claude.rs index 812b98f..0c5c80a 100644 --- a/crates/muxa/src/adapters/claude.rs +++ b/crates/muxa/src/adapters/claude.rs @@ -98,6 +98,7 @@ impl HookAdapter for ClaudeAdapter { session_id: input.session_id, surface: None, pane, + tmux_socket: None, cwd: input.cwd, }; let at = OffsetDateTime::now_utc(); @@ -338,6 +339,7 @@ pub fn statusline_heartbeat(input: StatusLineInput, pane: Option) -> Age session_id: input.session_id, surface: None, pane, + tmux_socket: None, cwd: input.cwd, }, model: input.model.and_then(|m| m.display_name), diff --git a/crates/muxa/src/adapters/codex.rs b/crates/muxa/src/adapters/codex.rs index ba9ff6e..df229fa 100644 --- a/crates/muxa/src/adapters/codex.rs +++ b/crates/muxa/src/adapters/codex.rs @@ -67,6 +67,7 @@ impl HookAdapter for CodexAdapter { session_id: input.session_id, surface: None, pane, + tmux_socket: None, cwd: input.cwd, }; let at = OffsetDateTime::now_utc(); diff --git a/crates/muxa/src/adapters/gemini.rs b/crates/muxa/src/adapters/gemini.rs index 6e77e25..5d8544c 100644 --- a/crates/muxa/src/adapters/gemini.rs +++ b/crates/muxa/src/adapters/gemini.rs @@ -71,6 +71,7 @@ impl HookAdapter for GeminiAdapter { session_id: input.session_id, surface: None, pane, + tmux_socket: None, cwd: input.cwd, }; let at = OffsetDateTime::now_utc(); diff --git a/crates/muxa/src/adapters/hook.rs b/crates/muxa/src/adapters/hook.rs index f7e742b..6262f4a 100644 --- a/crates/muxa/src/adapters/hook.rs +++ b/crates/muxa/src/adapters/hook.rs @@ -69,9 +69,24 @@ where if let Some(surface) = surface { ev.id_mut().surface = Some(surface); } + if ev.id_mut().tmux_socket.is_none() { + ev.id_mut().tmux_socket = tmux_socket_env(); + } Ok(ev) } +/// The tmux server socket path from `$TMUX` (`",,"`), +/// when the hook process runs inside tmux. Empty/absent yields `None`. +fn tmux_socket_env() -> Option { + let value = std::env::var("TMUX").ok()?; + let path = value.split(',').next()?.trim(); + if path.is_empty() { + None + } else { + Some(path.to_string()) + } +} + fn muxa_session_env() -> Option { let id = std::env::var("MUXA_SESSION_ID") .ok() diff --git a/crates/muxa/src/adapters/opencode.rs b/crates/muxa/src/adapters/opencode.rs index ca9c734..0ed79c1 100644 --- a/crates/muxa/src/adapters/opencode.rs +++ b/crates/muxa/src/adapters/opencode.rs @@ -43,6 +43,7 @@ pub fn normalize_event(input: Value, pane: Option) -> AgentEvent { session_id: session_id(&input).unwrap_or_else(|| "opencode-unknown".into()), surface: None, pane, + tmux_socket: None, cwd: cwd(&input), }; diff --git a/crates/muxa/src/backend/mod.rs b/crates/muxa/src/backend/mod.rs index f95edf9..6e86c60 100644 --- a/crates/muxa/src/backend/mod.rs +++ b/crates/muxa/src/backend/mod.rs @@ -483,6 +483,7 @@ mod tests { fn fake_pane(id: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: "z".into(), window_index: "0".into(), @@ -530,6 +531,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, diff --git a/crates/muxa/src/backend/zellij.rs b/crates/muxa/src/backend/zellij.rs index e158d75..25268ba 100644 --- a/crates/muxa/src/backend/zellij.rs +++ b/crates/muxa/src/backend/zellij.rs @@ -204,6 +204,7 @@ mod tests { fn fake_pane(id: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: "z".into(), window_index: "0".into(), diff --git a/crates/muxa/src/dashboard/server.rs b/crates/muxa/src/dashboard/server.rs index 37c533a..a031a0f 100644 --- a/crates/muxa/src/dashboard/server.rs +++ b/crates/muxa/src/dashboard/server.rs @@ -872,6 +872,7 @@ mod tests { .store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind, session_id: sid.into(), surface: None, @@ -1208,6 +1209,7 @@ mod tests { .store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "s1".into(), surface: None, @@ -1251,6 +1253,7 @@ mod tests { .store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "snap-1".into(), surface: None, @@ -1320,6 +1323,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "s1".into(), surface: None, @@ -1348,6 +1352,7 @@ mod tests { store_for_task .apply(&AgentEvent::NotificationFired { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "s1".into(), surface: None, @@ -1391,6 +1396,8 @@ mod tests { let mut stream = BroadcastStream::new(rx); let agent = Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "s1".into(), surface: None, @@ -1496,6 +1503,8 @@ mod tests { // Fill + overflow the buffer. Capacity is 2; we send 5 so the // receiver is now 3 messages behind. let agent = Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "lag".into(), surface: None, @@ -1586,6 +1595,7 @@ mod tests { .store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind, session_id: sid.into(), surface: None, diff --git a/crates/muxa/src/discovery.rs b/crates/muxa/src/discovery.rs index 6f54630..18cc9af 100644 --- a/crates/muxa/src/discovery.rs +++ b/crates/muxa/src/discovery.rs @@ -251,6 +251,7 @@ pub fn synthesize_started(d: &Discovered, at: OffsetDateTime) -> AgentEvent { session_id: format!("{}{}", SYNTHETIC_SESSION_PREFIX, d.pane.pane_id), surface: None, pane: Some(d.pane.pane_id.clone()), + tmux_socket: d.pane.socket.clone(), cwd: None, }, at, @@ -351,6 +352,7 @@ mod tests { fn pane(id: &str, cmd: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: "s".into(), window_index: "0".into(), diff --git a/crates/muxa/src/event.rs b/crates/muxa/src/event.rs index d824ffe..569226e 100644 --- a/crates/muxa/src/event.rs +++ b/crates/muxa/src/event.rs @@ -82,6 +82,13 @@ pub struct AgentId { #[serde(default, skip_serializing_if = "Option::is_none")] pub surface: Option, pub pane: Option, + /// Absolute path of the tmux server socket `pane` belongs to (the first + /// comma-separated field of `$TMUX` at hook time). Pane ids are only + /// unique per server, so this disambiguates e.g. amux's dedicated + /// `-L amux` server from the default one. Optional and purely additive + /// on the wire — old adapters simply never send it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tmux_socket: Option, pub cwd: Option, } @@ -293,6 +300,7 @@ mod tests { fn round_trip_prompt_submitted() { let ev = AgentEvent::PromptSubmitted { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "sess-1".into(), surface: None, @@ -335,6 +343,7 @@ mod tests { fn turn_stopped_without_response_omits_field_in_json() { let ev = AgentEvent::TurnStopped { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "s".into(), surface: None, diff --git a/crates/muxa/src/ipc.rs b/crates/muxa/src/ipc.rs index f054a22..75d637e 100644 --- a/crates/muxa/src/ipc.rs +++ b/crates/muxa/src/ipc.rs @@ -1485,6 +1485,7 @@ mod tests { client .ingest(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "sess-a".into(), surface: None, @@ -1525,6 +1526,7 @@ mod tests { // Drive a state transition: Started → Idle (initial). let id = AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "sub-test".into(), surface: None, @@ -1892,6 +1894,7 @@ mod tests { wait_for_socket(&sock).await; let id = AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "v1-test".into(), surface: None, @@ -2012,6 +2015,7 @@ mod tests { wait_for_socket(&sock).await; let id = AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "v2-test".into(), surface: None, @@ -2110,6 +2114,7 @@ mod tests { assert!(backend.list_panes().is_empty()); let pane = PaneInfo { + socket: None, pane_id: "zellij:3".into(), session: "z".into(), window_index: "0".into(), diff --git a/crates/muxa/src/notify.rs b/crates/muxa/src/notify.rs index 5d21e82..5fb8037 100644 --- a/crates/muxa/src/notify.rs +++ b/crates/muxa/src/notify.rs @@ -226,6 +226,8 @@ mod tests { fn agent(state: AgentState) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "s".into(), surface: None, diff --git a/crates/muxa/src/reconcile.rs b/crates/muxa/src/reconcile.rs index e32ce10..8f70e43 100644 --- a/crates/muxa/src/reconcile.rs +++ b/crates/muxa/src/reconcile.rs @@ -206,6 +206,7 @@ impl Reconciler { session_id: a.session_id, surface: a.surface, pane: a.pane, + tmux_socket: None, cwd: a.cwd, }, cur_5h_pct: a.rate_limit_5h_pct, @@ -484,6 +485,7 @@ mod tests { fn pane(id: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: "s".into(), window_index: "0".into(), @@ -502,6 +504,7 @@ mod tests { session_id: sid.into(), surface: None, pane: Some(pane_id.into()), + tmux_socket: None, cwd: None, }, at, @@ -622,6 +625,8 @@ mod tests { let t0 = datetime!(2026-04-24 12:00:00 UTC); let mk = |sid: &str, state: AgentState| Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, @@ -676,6 +681,7 @@ mod tests { session_id: sid.into(), surface: None, pane: Some(pane_id.into()), + tmux_socket: None, cwd: None, }, at, diff --git a/crates/muxa/src/sinks/ohmyprompt.rs b/crates/muxa/src/sinks/ohmyprompt.rs index f938dee..17d519e 100644 --- a/crates/muxa/src/sinks/ohmyprompt.rs +++ b/crates/muxa/src/sinks/ohmyprompt.rs @@ -579,6 +579,7 @@ mod tests { fn rec(kind: AgentKind, prompt: &str, cwd: Option<&str>, model: Option<&str>) -> PromptRecord { PromptRecord { id: AgentId { + tmux_socket: None, kind, session_id: "sess-1".into(), surface: None, diff --git a/crates/muxa/src/sinks/webhook.rs b/crates/muxa/src/sinks/webhook.rs index e021feb..b6f8168 100644 --- a/crates/muxa/src/sinks/webhook.rs +++ b/crates/muxa/src/sinks/webhook.rs @@ -483,6 +483,8 @@ mod tests { fn agent(kind: AgentKind, pane: Option<&str>, prompt: Option<&str>) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind, session_id: "sess-1".into(), surface: None, diff --git a/crates/muxa/src/snapshot.rs b/crates/muxa/src/snapshot.rs index c75aaa9..c56c82d 100644 --- a/crates/muxa/src/snapshot.rs +++ b/crates/muxa/src/snapshot.rs @@ -361,6 +361,7 @@ mod tests { fn started_event(sid: &str, pane: &str, at: OffsetDateTime) -> AgentEvent { AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, diff --git a/crates/muxa/src/state.rs b/crates/muxa/src/state.rs index c644b63..c4c6966 100644 --- a/crates/muxa/src/state.rs +++ b/crates/muxa/src/state.rs @@ -23,7 +23,7 @@ use crate::process_tree::WorkloadSummary; use crate::tmux::PaneInfo; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use time::OffsetDateTime; @@ -82,6 +82,18 @@ pub struct Agent { #[serde(default, skip_serializing_if = "Option::is_none")] pub surface: Option, pub pane: Option, + /// Short name of the tmux server socket `pane` lives on (the socket + /// file's basename, e.g. `default` or `amux`) — from the adapter's + /// `$TMUX` at hook time, or backfilled by the reconciler's pane scan. + /// Pane ids are only unique per server; wire consumers matching by pane + /// use this to disambiguate. Optional and purely additive on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tmux_socket: Option, + /// Name of the tmux session `pane` belongs to, backfilled by the + /// reconciler's multi-socket pane scan each tick. Optional and purely + /// additive on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tmux_session: Option, pub cwd: Option, /// OS process id for pid-tracked rows (`AgentKind::Task`). When set, /// the reconciler governs this agent's liveness by checking whether the @@ -207,6 +219,8 @@ impl Agent { session_id, surface, pane, + tmux_socket: None, + tmux_session: None, cwd, pid: None, workload: WorkloadSummary::default(), @@ -942,6 +956,14 @@ impl Store { if agent.pane.is_none() { agent.pane.clone_from(&id.pane); } + if agent.tmux_socket.is_none() { + // `$TMUX` carries the socket *path*; store the short name so it + // compares directly against the pane scan's socket tags. + agent.tmux_socket = id + .tmux_socket + .as_deref() + .map(crate::tmux::socket_short_name); + } if agent.surface.is_none() { agent.surface.clone_from(&id.surface); } @@ -1194,6 +1216,44 @@ impl Store { changed } + /// Backfills tmux session/socket names from the pane scan so agent rows + /// carry them on the wire (clients join workspaces by session name). + /// Deliberately silent — no state transition is broadcast for a name + /// refresh. Ambiguous untagged rows (same pane id on several servers, + /// agent without `$TMUX` info) prefer the default server. + fn backfill_tmux_names( + agents: &mut HashMap, + panes_by_id: &HashMap<&str, Vec<&PaneInfo>>, + ) { + for a in agents.values_mut() { + if a.pid.is_some() { + continue; + } + let Some(pane_id) = a.pane.as_deref() else { + continue; + }; + let Some(cands) = panes_by_id.get(pane_id) else { + continue; + }; + let chosen = match a.tmux_socket.as_deref() { + Some(sock) => cands.iter().find(|p| p.socket.as_deref() == Some(sock)), + None => match cands.len() { + 1 => cands.first(), + _ => cands + .iter() + .find(|p| p.socket.as_deref() == Some("default")) + .or(cands.first()), + }, + }; + if let Some(p) = chosen { + a.tmux_session = Some(p.session.clone()); + if a.tmux_socket.is_none() { + a.tmux_socket.clone_from(&p.socket); + } + } + } + } + /// Converge the registry against ground truth from tmux. /// /// This is the periodic control-loop pass — analogous to a Kubernetes @@ -1217,12 +1277,20 @@ impl Store { /// Idempotent and safe to run on a timer regardless of event traffic. /// Returns counts so callers can log non-trivial sweeps without spamming. pub async fn reconcile(&self, live_panes: &[PaneInfo]) -> ReconcileReport { - let live: HashSet<&str> = live_panes.iter().map(|p| p.pane_id.as_str()).collect(); let mut agents = self.agents.write().await; let mut report = ReconcileReport::default(); // Sweep 1: drop agents whose pane is gone. Done as a single retain // pass to avoid building an intermediate Vec of doomed session ids. + // Candidate panes per pane id: the scan spans every tmux server + // socket, and pane ids are only unique per server, so one id can + // map to several panes. Liveness and the session-name backfill both + // use the agent's `tmux_socket` (when known) to pick the right one. + let mut panes_by_id: HashMap<&str, Vec<&PaneInfo>> = HashMap::new(); + for p in live_panes { + panes_by_id.entry(p.pane_id.as_str()).or_default().push(p); + } + let before = agents.len(); agents.retain(|_, a| { // pid-tracked rows (Task) are governed by process liveness in @@ -1232,24 +1300,43 @@ impl Store { return true; } match a.pane.as_deref() { - Some(pane_id) => live.contains(pane_id), + Some(pane_id) => match (panes_by_id.get(pane_id), a.tmux_socket.as_deref()) { + (None, _) => false, + // Socket-aware rows must match a pane on THEIR server — + // a same-numbered pane on another server is not this + // agent's pane. Untagged candidates (bare-call rows) + // stay acceptable to avoid reaping across tmux quirks. + (Some(cands), Some(sock)) => cands + .iter() + .any(|p| p.socket.as_deref().is_none_or(|s| s == sock)), + (Some(_), None) => true, + }, None => true, // paneless agents (rare) aren't governed by tmux } }); report.stale_panes_reaped = before - agents.len(); - // Sweeps 2 & 3: per-pane dedup. Group surviving agents by pane id. + Self::backfill_tmux_names(&mut agents, &panes_by_id); + + // Sweeps 2 & 3: per-pane dedup. Group surviving agents by pane id + // AND server socket — pane ids repeat across tmux servers, and two + // agents on same-numbered panes of different servers are different + // agents, never duplicates. (The backfill above just stamped every + // pane-bearing agent's socket, so the key is populated.) // pid-tracked rows (Task) are excluded entirely — they're governed // by process liveness, never by pane ownership, so a task that // carries a `--pane` must not be deduped against the pane's real // agent (that would delete one of them). - let mut by_pane: HashMap<&str, Vec> = HashMap::new(); + let mut by_pane: HashMap<(&str, Option<&str>), Vec> = HashMap::new(); for (sid, a) in agents.iter() { if a.pid.is_some() { continue; } if let Some(p) = a.pane.as_deref() { - by_pane.entry(p).or_default().push(sid.clone()); + by_pane + .entry((p, a.tmux_socket.as_deref())) + .or_default() + .push(sid.clone()); } } @@ -1347,6 +1434,7 @@ mod tests { fn id(session: &str) -> AgentId { AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: session.into(), surface: None, @@ -1363,6 +1451,8 @@ mod tests { ) -> Agent { let at = datetime!(2026-05-05 12:00:00 UTC); Agent { + tmux_socket: None, + tmux_session: None, kind, session_id: session.into(), surface: None, @@ -1427,6 +1517,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "claude-1".into(), surface: None, @@ -1525,6 +1616,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "agent".into(), surface: None, @@ -1567,6 +1659,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "agent".into(), surface: None, @@ -1587,6 +1680,7 @@ mod tests { .await .unwrap(); let pane = PaneInfo { + socket: None, pane_id: "%1".into(), session: "s".into(), window_index: "0".into(), @@ -2811,6 +2905,7 @@ mod tests { // First synthetic from `muxa sync` lands an Idle agent on %1. let synthetic = AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "synthetic-%1".into(), surface: None, @@ -2849,6 +2944,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "synthetic-%7".into(), surface: None, @@ -2865,6 +2961,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "real-sess".into(), surface: None, @@ -2892,6 +2989,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "real-sess".into(), surface: None, @@ -2906,6 +3004,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "synthetic-%9".into(), surface: None, @@ -2958,6 +3057,7 @@ mod tests { fn pane(id: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: "s".into(), window_index: "0".into(), @@ -2987,6 +3087,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "real".into(), surface: None, @@ -2999,6 +3100,7 @@ mod tests { store .apply(&AgentEvent::SessionEnded { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "real".into(), surface: None, @@ -3016,6 +3118,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "synthetic-%5".into(), surface: None, @@ -3040,6 +3143,71 @@ mod tests { ); } + /// One pane id, two servers: the reconciler must backfill each agent's + /// `tmux_session` from the pane on ITS server (socket-tagged rows), and + /// prefer the default server for legacy untagged rows. + #[tokio::test] + async fn reconcile_backfills_session_names_per_socket() { + let store = Store::shared(); + let t0 = datetime!(2026-04-24 12:00:00 UTC); + let mk_started = |sid: &str, socket: Option<&str>| AgentEvent::Started { + id: AgentId { + kind: AgentKind::ClaudeCode, + session_id: sid.into(), + surface: None, + pane: Some("%1".into()), + tmux_socket: socket.map(Into::into), + cwd: None, + }, + at: t0, + }; + store + .apply(&mk_started("on-amux", Some("/tmp/tmux-501/amux"))) + .await; + store.apply(&mk_started("untagged", None)).await; + + let mut amux_pane = pane("%1"); + amux_pane.socket = Some("amux".into()); + amux_pane.session = "amux-spike".into(); + let mut default_pane = pane("%1"); + default_pane.socket = Some("default".into()); + default_pane.session = "main".into(); + store.reconcile(&[amux_pane, default_pane]).await; + + let on_amux = store.by_session("on-amux").await.expect("agent kept"); + assert_eq!(on_amux.tmux_socket.as_deref(), Some("amux")); + assert_eq!(on_amux.tmux_session.as_deref(), Some("amux-spike")); + let untagged = store.by_session("untagged").await.expect("agent kept"); + assert_eq!(untagged.tmux_session.as_deref(), Some("main")); + assert_eq!(untagged.tmux_socket.as_deref(), Some("default")); + } + + /// A socket-tagged agent whose server no longer has its pane is reaped + /// even when another server has a same-numbered pane. + #[tokio::test] + async fn reconcile_reaps_socket_tagged_agent_when_its_server_lacks_the_pane() { + let store = Store::shared(); + let t0 = datetime!(2026-04-24 12:00:00 UTC); + store + .apply(&AgentEvent::Started { + id: AgentId { + kind: AgentKind::ClaudeCode, + session_id: "amux-only".into(), + surface: None, + pane: Some("%9".into()), + tmux_socket: Some("/tmp/tmux-501/amux".into()), + cwd: None, + }, + at: t0, + }) + .await; + let mut default_pane = pane("%9"); + default_pane.socket = Some("default".into()); + let report = store.reconcile(&[default_pane]).await; + assert_eq!(report.stale_panes_reaped, 1); + assert!(store.by_session("amux-only").await.is_none()); + } + #[tokio::test] async fn reconcile_reaps_agents_whose_pane_is_gone() { let store = Store::shared(); @@ -3048,6 +3216,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, @@ -3078,6 +3247,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "no-pane".into(), surface: None, @@ -3110,6 +3280,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "synthetic-%1".into(), surface: None, @@ -3130,6 +3301,8 @@ mod tests { agents.insert( "real-old".into(), Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "real-old".into(), surface: None, @@ -3159,6 +3332,8 @@ mod tests { agents.insert( "real-new".into(), Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: "real-new".into(), surface: None, @@ -3217,6 +3392,8 @@ mod tests { agents.insert( sid.into(), Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, @@ -3266,6 +3443,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "live".into(), surface: None, @@ -3279,6 +3457,8 @@ mod tests { // Two candidates: "live" already exists (must skip), // "fresh" is new (must insert). let mk = |sid: &str, pane: &str, prompt: &str| Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::ClaudeCode, session_id: sid.into(), surface: None, @@ -3433,6 +3613,7 @@ mod tests { store .apply(&AgentEvent::Started { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: "lone".into(), surface: None, diff --git a/crates/muxa/src/timeline.rs b/crates/muxa/src/timeline.rs index b4cf026..4349618 100644 --- a/crates/muxa/src/timeline.rs +++ b/crates/muxa/src/timeline.rs @@ -2234,6 +2234,8 @@ mod tests { last_activity_at: OffsetDateTime, ) -> Agent { Agent { + tmux_socket: None, + tmux_session: None, kind: AgentKind::Codex, session_id: session_id.into(), surface: None, diff --git a/crates/muxa/src/tmux/mod.rs b/crates/muxa/src/tmux/mod.rs index 3989edb..7eb6f2e 100644 --- a/crates/muxa/src/tmux/mod.rs +++ b/crates/muxa/src/tmux/mod.rs @@ -132,6 +132,25 @@ pub struct PaneInfo { /// truncated lines from older tmux) leave it zeroed out, and downstream /// discovery treats `0` as "no process tree to walk." pub pane_pid: u32, + /// Short name of the tmux server socket this pane lives on (the socket + /// file's basename, e.g. `default` or `amux`). Pane ids are only unique + /// per server, so consumers matching by pane id use this to + /// disambiguate. `None` when the row came from a bare `tmux` call whose + /// socket couldn't be named (or from a backend without sockets). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub socket: Option, +} + +/// The short display name for a tmux socket path: its file basename +/// (`/private/tmp/tmux-501/amux` → `amux`). Falls back to the input when +/// there is no basename. Used to compare `$TMUX`-derived paths against +/// scanner socket names. +pub fn socket_short_name(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_string() } #[derive(Debug, Clone)] @@ -192,6 +211,12 @@ pub(crate) const CLIENT_FMT: &str = /// 0.5.x; rows from older `PANE_FMT` outputs (or other backends that /// don't emit it) get `pane_pid = 0`. pub(crate) fn parse_pane_lines(stdout: &str) -> Vec { + parse_pane_lines_for_socket(stdout, None) +} + +/// `parse_pane_lines` with the originating server's socket short name +/// stamped on every row (see [`PaneInfo::socket`]). +pub(crate) fn parse_pane_lines_for_socket(stdout: &str, socket: Option<&str>) -> Vec { let mut panes = Vec::new(); for line in stdout.lines() { let cols: Vec<&str> = line.split('\t').collect(); @@ -208,6 +233,7 @@ pub(crate) fn parse_pane_lines(stdout: &str) -> Vec { current_command: cols[5].into(), title: cols[6].into(), pane_pid, + socket: socket.map(Into::into), }); } panes @@ -270,7 +296,8 @@ pub fn list_panes() -> Result, TmuxError> { match tmux_output(&["-S", sock_str, "list-panes", "-a", "-F", PANE_FMT]) { Ok(o) if o.status.success() => { let stdout = String::from_utf8_lossy(&o.stdout); - all.extend(parse_pane_lines(&stdout)); + let socket = socket_short_name(sock_str); + all.extend(parse_pane_lines_for_socket(&stdout, Some(&socket))); } Ok(o) => { let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string(); diff --git a/crates/muxa/src/tmux/scanner.rs b/crates/muxa/src/tmux/scanner.rs index 73ef5b2..75d9aec 100644 --- a/crates/muxa/src/tmux/scanner.rs +++ b/crates/muxa/src/tmux/scanner.rs @@ -24,7 +24,7 @@ //! `/api/events`) is host-agnostic. This is intentional and matches //! the design doc's "Zellij has no multi-server enumeration" note. -use crate::tmux::{parse_pane_lines, PaneInfo, PANE_FMT}; +use crate::tmux::{PaneInfo, PANE_FMT}; use serde::Serialize; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -239,7 +239,11 @@ async fn list_panes_for_socket(sock: PathBuf) -> Result, String> { return Err(stderr); } let stdout = String::from_utf8_lossy(&out.stdout); - Ok(parse_pane_lines(&stdout)) + let socket = crate::tmux::socket_short_name(sock_str); + Ok(crate::tmux::parse_pane_lines_for_socket( + &stdout, + Some(&socket), + )) } fn is_no_server_running(stderr: &str) -> bool { @@ -350,6 +354,7 @@ mod tests { use std::sync::Arc; fn fake_pane(id: &str, session: &str) -> PaneInfo { PaneInfo { + socket: None, pane_id: id.into(), session: session.into(), window_index: "0".into(), diff --git a/crates/muxa/tests/oh_my_prompt_sink.rs b/crates/muxa/tests/oh_my_prompt_sink.rs index ad0c487..5720dd2 100644 --- a/crates/muxa/tests/oh_my_prompt_sink.rs +++ b/crates/muxa/tests/oh_my_prompt_sink.rs @@ -33,6 +33,7 @@ fn unique_token_var() -> String { fn make_prompt(prompt: &str, session: &str) -> PromptRecord { PromptRecord { id: AgentId { + tmux_socket: None, kind: AgentKind::ClaudeCode, session_id: session.into(), surface: None, diff --git a/crates/muxad/src/main.rs b/crates/muxad/src/main.rs index b86408c..bb78165 100644 --- a/crates/muxad/src/main.rs +++ b/crates/muxad/src/main.rs @@ -838,6 +838,8 @@ async fn enrich_from_history( session_id: entry.session_id, surface: None, pane: Some(pane.pane_id.clone()), + tmux_socket: pane.socket.clone(), + tmux_session: Some(pane.session.clone()), cwd: entry.cwd, pid: None, workload: muxa::WorkloadSummary::default(),