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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.

Expand All @@ -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",
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions crates/muxa-cli/src/attend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/muxa-cli/src/dashboard_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions crates/muxa-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions crates/muxa-cli/src/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/muxa-cli/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions crates/muxa/benches/store_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/muxa/src/adapters/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -338,6 +339,7 @@ pub fn statusline_heartbeat(input: StatusLineInput, pane: Option<String>) -> Age
session_id: input.session_id,
surface: None,
pane,
tmux_socket: None,
cwd: input.cwd,
},
model: input.model.and_then(|m| m.display_name),
Expand Down
1 change: 1 addition & 0 deletions crates/muxa/src/adapters/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/muxa/src/adapters/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions crates/muxa/src/adapters/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`"<socket>,<pid>,<session>"`),
/// when the hook process runs inside tmux. Empty/absent yields `None`.
fn tmux_socket_env() -> Option<String> {
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<SurfaceRef> {
let id = std::env::var("MUXA_SESSION_ID")
.ok()
Expand Down
1 change: 1 addition & 0 deletions crates/muxa/src/adapters/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub fn normalize_event(input: Value, pane: Option<String>) -> AgentEvent {
session_id: session_id(&input).unwrap_or_else(|| "opencode-unknown".into()),
surface: None,
pane,
tmux_socket: None,
cwd: cwd(&input),
};

Expand Down
2 changes: 2 additions & 0 deletions crates/muxa/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -530,6 +531,7 @@ mod tests {
store
.apply(&AgentEvent::Started {
id: AgentId {
tmux_socket: None,
kind: AgentKind::ClaudeCode,
session_id: sid.into(),
surface: None,
Expand Down
1 change: 1 addition & 0 deletions crates/muxa/src/backend/zellij.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions crates/muxa/src/dashboard/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@ mod tests {
.store
.apply(&AgentEvent::Started {
id: AgentId {
tmux_socket: None,
kind,
session_id: sid.into(),
surface: None,
Expand Down Expand Up @@ -1208,6 +1209,7 @@ mod tests {
.store
.apply(&AgentEvent::Started {
id: AgentId {
tmux_socket: None,
kind: AgentKind::ClaudeCode,
session_id: "s1".into(),
surface: None,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1320,6 +1323,7 @@ mod tests {
store
.apply(&AgentEvent::Started {
id: AgentId {
tmux_socket: None,
kind: AgentKind::ClaudeCode,
session_id: "s1".into(),
surface: None,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1586,6 +1595,7 @@ mod tests {
.store
.apply(&AgentEvent::Started {
id: AgentId {
tmux_socket: None,
kind,
session_id: sid.into(),
surface: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/muxa/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions crates/muxa/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ pub struct AgentId {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface: Option<SurfaceRef>,
pub pane: Option<String>,
/// 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<String>,
pub cwd: Option<String>,
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading