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 docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export default defineConfig({
items: [
{ label: "Overview", slug: "guide/sidebar" },
{ label: "Customization", slug: "guide/sidebar/customization" },
{ label: "Remote agents", slug: "guide/sidebar/remote-agents" },
],
},
{
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/guide/sidebar/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ sidebars without a restart.
| `{window_index}` | Tmux window number as shown in the status bar (e.g. `3` for `3:wm-fix-auth`), usable with `prefix+N`. Empty on backends without window indexes. |
| `{pane_title}` | Sanitized agent task title from the pane title. |
| `{pane_suffix}` | Disambiguator like `(1)`, `(2)` when multiple agents share a window. Empty otherwise. |
| `{remote}` | Dimmed `@<host>` tag for agents mirrored from another machine (see [Remote agents](/guide/sidebar/remote-agents/)). Empty for local agents. |
| `{status_icon}` | Status indicator (working spinner, waiting, done, sleeping, etc.). |
| `{agent_icon}` | Per-agent icon based on the running agent's profile (see [Agent identity](#agent-identity)). |
| `{agent_label}` | Capitalized agent name (e.g. `Claude`, `Codex`). |
Expand Down
155 changes: 155 additions & 0 deletions docs/src/content/docs/guide/sidebar/remote-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
title: "Remote agents"
description: Show agents running on other machines in the local sidebar and jump to them over ssh
---

The sidebar (and dashboard) can display agents that run inside a tmux server on
**another machine** - for example claudes living on a work server you keep
attached in an `ssh ... 'tmux attach'` pane. Remote agents appear next to local
ones with a dimmed `@<host>` tag, show live status, and jumping to them works.

:::note
tmux backend only. Remote agents come from mirrored state files (see below);
workmux does not open any network connections on its own except when you jump.
:::

## How it works

Workmux stores one JSON state file per agent pane in
`~/.local/state/workmux/agents/`. The remote machine runs stock workmux with
normal status hooks, producing state files for **its** tmux server. A small
sync of your choosing mirrors those files to the local machine, rewriting the
`pane_key.instance` field to `ssh:<host>`. The local reconcile pass recognizes
the `ssh:` prefix and:

- exposes those agents with a namespaced pane id `ssh:<host>/%N`
- skips local liveness checks for them - there is no local pane to check
against, so the mirror is taken at face value
- routes jumps to them over ssh instead of `switch-client`

That last point makes the sync responsible for liveness: a mirrored file is
assumed to describe an agent that exists. Getting that wrong is the one way
this feature goes bad, so the contract below spells it out.

## Setting up the mirror

Any sync loop works as long as it:

1. copies `~/.local/state/workmux/agents/*.json` from the remote host every
second or so (a persistent ssh `ControlMaster` connection makes this cheap),
2. **mirrors an agent only while its pane is live on the remote** (see below),
3. rewrites `pane_key.instance` to `ssh:<host>` and renames the file to match
(`tmux__ssh%3A<host>__<pane>.json` - the filename encodes `/ \ : %`),
4. deletes the mirrored files when the remote becomes unreachable, so dead
tiles disappear instead of going stale,
5. optionally signals the sidebar daemon for an instant refresh:
`pkill -USR1 -f 'workmux _sidebar-daemon'`.

### Why step 2 matters

State files outlive their panes - nothing prunes them unless a workmux daemon
happens to be running on that machine - so a wholesale copy pins dead agents to
your sidebar until you notice and delete the files by hand. Ask the remote tmux
in the same round trip and keep a state file only when it still agrees:

| state field | remote tmux | catches |
| --- | --- | --- |
| `pane_key.pane_id` | listed by `list-panes -a` | pane closed |
| `boot_id` | `#{start_time}` | tmux server restarted, every old pane is gone |
| `pane_pid` | `#{pane_pid}` | pane id recycled by a new shell |
| `command` | `#{pane_current_command}` | agent exited, shell took the pane back |

These are the same checks the local reconcile pass runs against local panes.

:::tip
If your only window into that machine is an `ssh`/`autossh` pane inside your
local tmux, consider mirroring **only while such a pane exists**. An agent you
cannot jump to is not worth a tile, and it makes the mirror collapse on its own
after a reboot instead of waiting for the next successful sync.
:::

Minimal example (run it under a process supervisor of your choice):

```bash
#!/usr/bin/env bash
# Mirror workmux agent state from HOST into the local state dir, keeping only
# agents whose pane is still live over there. Needs jq and base64.
HOST=s
AGENTS=~/.local/state/workmux/agents
ENC_HOST=$(printf '%s' "ssh:$HOST" | sed 's/%/%25/g; s/:/%3A/g; s#/#%2F#g')
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=5
-o ControlMaster=auto -o ControlPath=/tmp/wm-sync-%C -o ControlPersist=120)
# One round trip: server boot id, live panes, marker, then the state files as
# a base64 tar (they are pretty-printed JSON, so they cannot be line-parsed).
REMOTE='tmux display-message -p "B #{start_time}" 2>/dev/null
tmux list-panes -a -F "P #{pane_id} #{pane_pid} #{pane_current_command}" 2>/dev/null
echo @@AGENTS@@
cd ~/.local/state/workmux/agents 2>/dev/null &&
ls *.json >/dev/null 2>&1 && tar cf - *.json | base64
exit 0'
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
mkdir -p "$AGENTS"

while sleep 1; do
if ! out=$(ssh "${SSH_OPTS[@]}" "$HOST" "$REMOTE" 2>/dev/null); then
rm -f "$AGENTS"/tmux__"$ENC_HOST"__*.json # remote gone -> clear mirror
continue
fi
panes=${out%%@@AGENTS@@*}
boot=$(awk '$1=="B" {print $2; exit}' <<<"$panes")
rm -f "$tmp"/*.json
printf '%s' "${out#*@@AGENTS@@}" | base64 -d 2>/dev/null | tar xf - -C "$tmp" 2>/dev/null

seen=""
for src in "$tmp"/*.json; do
[ -e "$src" ] || continue
pane=$(jq -r '.pane_key.pane_id // empty' "$src")
[ -n "$pane" ] || continue
# the pane as tmux sees it right now - no row means the agent is gone
pid=""; cmd=""
read -r _ _ pid cmd < <(awk -v p="$pane" '$1=="P" && $2==p {print; exit}' <<<"$panes")
[ -n "$pid" ] || continue
# server restarted / pane id recycled / agent exited -> not the same agent
[ "$(jq -r '.boot_id // empty' "$src")" = "$boot" ] || continue
[ "$(jq -r '.pane_pid // empty' "$src")" = "$pid" ] || continue
[ "$(jq -r '.command // empty' "$src")" = "$cmd" ] || continue

f="$AGENTS/tmux__${ENC_HOST}__$(printf '%s' "$pane" | sed 's/%/%25/g').json"
jq -c --arg i "ssh:$HOST" '.pane_key.instance=$i' "$src" >"$f.tmp" && mv "$f.tmp" "$f"
seen="$seen $f"
done
for f in "$AGENTS"/tmux__"$ENC_HOST"__*.json; do
[ -e "$f" ] || continue
case " $seen " in *" $f "*) ;; *) rm -f "$f";; esac
done
pkill -USR1 -f 'workmux _sidebar-daemon' 2>/dev/null
done
```

## Jumping to a remote agent

Selecting a remote agent (sidebar `Enter`/click, `workmux sidebar jump N`,
dashboard, `last-done`, `last-agent`) does two things:

1. **Locally**: if a local pane hosts the ssh/autossh client for that host
(detected by the client process's argv on the pane's tty), the most
recently used such pane is brought on screen - that pane is your window
into the remote tmux. If the remote view lives outside tmux (e.g. a
terminal tab), local focus is left alone.
2. **Remotely** (detached ssh, does not block the UI): the agent's window
becomes the active window in **every attached session** of the remote
server. Grouped sessions keep independent active-window pointers, so all
attached views follow.

The jumped-to agent is highlighted as active in the sidebar until you switch
to a different local window.

## Display

- The `{remote}` template token renders a dimmed `@<host>` next to the agent
name (wired into the default templates; see
[Customization](/guide/sidebar/customization/)).
- Git stats, PR checks, and output-preview capture are skipped for remote
agents - those need the paths and panes locally.
- Status icons behave as usual, except the done icon does not auto-clear on
focus (the window that would clear it lives on the remote machine).
46 changes: 33 additions & 13 deletions src/command/sidebar/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ fn host_agent_index(
})
}

/// Index of the remote agent recorded as active by a jump, if it is present.
fn remote_active_index(remote_active: Option<&str>, agents: &[AgentPane]) -> Option<usize> {
remote_active.and_then(|rid| agents.iter().position(|a| a.pane_id == rid))
}

/// Whether the sidebar auto-follows its host window or the user is navigating manually.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectionMode {
Expand Down Expand Up @@ -160,14 +165,15 @@ impl ResolvedAgentIcons {
}
}

const DEFAULT_COMPACT_TEMPLATE: &str = "{status_icon} {primary} {pane_suffix} {fill} {elapsed}";
const DEFAULT_COMPACT_TEMPLATE: &str =
"{status_icon} {primary}#[dim]{remote}#[default] {pane_suffix} {fill} {elapsed}";
const DEFAULT_TILE_TEMPLATES: &[&str] = &[
"{primary} {pane_suffix} {fill} {elapsed}",
"{primary}#[dim]{remote}#[default] {pane_suffix} {fill} {elapsed}",
"{secondary} {fill} {git_stats}",
"{pane_title} {fill} {pr_checks}",
];
const DEFAULT_HORIZONTAL_TEMPLATES: &[&str] = &[
"{status_icon} {primary} {pane_suffix} {fill} {elapsed}",
"{status_icon} {primary}#[dim]{remote}#[default] {pane_suffix} {fill} {elapsed}",
"{secondary} {fill} {git_stats}",
"{pane_title} {fill} {pr_checks}",
];
Expand Down Expand Up @@ -233,6 +239,7 @@ pub struct SidebarApp {
host_identity: Option<HostIdentity>,
/// Index of the agent in the sidebar's host window (updated each snapshot)
pub host_agent_idx: Option<usize>,
pub remote_active_pane_id: Option<String>,
/// Whether this sidebar's host window is the active window in the session
host_window_active: bool,
selection_mode: SelectionMode,
Expand Down Expand Up @@ -314,6 +321,7 @@ impl SidebarApp {
window_prefix: "wm-".to_string(),
host_identity: None,
host_agent_idx: None,
remote_active_pane_id: None,
host_window_active: true,
selection_mode: SelectionMode::FollowHost,
git_statuses: HashMap::new(),
Expand Down Expand Up @@ -396,6 +404,7 @@ impl SidebarApp {
window_prefix,
host_identity,
host_agent_idx: None,
remote_active_pane_id: None,
host_window_active: true,
selection_mode: SelectionMode::FollowHost,
git_statuses: HashMap::new(),
Expand Down Expand Up @@ -432,11 +441,17 @@ impl SidebarApp {
// Compute host agent index from the new snapshot first so that a
// config_version bump anchors the reload to the *current* host path,
// not whatever was selected from the previous snapshot.
self.host_agent_idx = host_agent_index(
&snapshot.agents,
self.host_window_id(),
&snapshot.active_pane_ids,
);
self.remote_active_pane_id = snapshot.remote_active_pane_id.clone();
self.host_agent_idx =
remote_active_index(self.remote_active_pane_id.as_deref(), &snapshot.agents).or_else(
|| {
host_agent_index(
&snapshot.agents,
self.host_window_id(),
&snapshot.active_pane_ids,
)
},
);

if snapshot.config_version != self.last_config_version {
self.last_config_version = snapshot.config_version;
Expand Down Expand Up @@ -482,11 +497,16 @@ impl SidebarApp {
{
self.agents.retain(|a| a.session == host_session);
// Recompute host_agent_idx after filtering
self.host_agent_idx = host_agent_index(
&self.agents,
self.host_window_id(),
&snapshot.active_pane_ids,
);
self.host_agent_idx =
remote_active_index(self.remote_active_pane_id.as_deref(), &self.agents).or_else(
|| {
host_agent_index(
&self.agents,
self.host_window_id(),
&snapshot.active_pane_ids,
)
},
);
}

// Restore selection
Expand Down
30 changes: 30 additions & 0 deletions src/command/sidebar/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,29 @@ fn read_sleeping_panes() -> HashSet<String> {
.unwrap_or_default()
}

/// Remote-agent highlight override recorded by `remote_pane_jump` as
/// "<namespaced_pane_id>|<window_id>". Stays valid while the recorded window
/// is still active somewhere; cleared once the user moves to another window.
fn read_remote_active(state: &TmuxState) -> Option<String> {
let raw = Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_remote_active"])
.run_and_capture_stdout()
.ok()?;
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (rid, wid) = raw.split_once('|')?;
if state.active_windows.iter().any(|(_, w)| w == wid) {
Some(rid.to_string())
} else {
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_remote_active"])
.run();
None
}
}

/// Shared git status cache, updated by a background worker thread.
type GitCache = Arc<Mutex<HashMap<PathBuf, GitStatus>>>;

Expand Down Expand Up @@ -1661,6 +1684,7 @@ pub fn run() -> Result<()> {
};
let filter_mode = read_sidebar_filter_mode();
let sleeping_pane_ids = read_sleeping_panes();
let remote_active_pane_id = read_remote_active(&tmux_state);
let git_statuses = git_cache.lock().ok().map(|c| c.clone()).unwrap_or_default();
let pr_statuses = pr_cache.lock().ok().map(|c| c.clone()).unwrap_or_default();
let check_statuses = check_cache
Expand Down Expand Up @@ -1692,6 +1716,7 @@ pub fn run() -> Result<()> {
pr_statuses,
check_statuses,
sleeping_pane_ids,
remote_active_pane_id,
},
&mut inactivity_tracker,
&last_interrupted,
Expand Down Expand Up @@ -1862,6 +1887,7 @@ struct TickInput {
pr_statuses: HashMap<PathBuf, PrPathEntry>,
check_statuses: HashMap<PathBuf, CheckPathEntry>,
sleeping_pane_ids: HashSet<String>,
remote_active_pane_id: Option<String>,
}

/// A state-file write to apply after computing the tick.
Expand Down Expand Up @@ -1908,6 +1934,7 @@ fn compute_tick(
pr_statuses,
check_statuses,
sleeping_pane_ids,
remote_active_pane_id,
} = input;

// Phase 1: Inactivity detection
Expand Down Expand Up @@ -1948,6 +1975,7 @@ fn compute_tick(
&sleeping_pane_ids,
);
snapshot.interrupted_pane_ids = interrupted.clone();
snapshot.remote_active_pane_id = remote_active_pane_id;

// Phase 4: Determine runtime write side effect
let runtime_write = if interrupted != *last_interrupted || heartbeat_due {
Expand Down Expand Up @@ -2005,6 +2033,7 @@ fn gather_captures(
agents
.iter()
.filter(|a| a.status == Some(crate::multiplexer::AgentStatus::Working))
.filter(|a| !a.pane_id.starts_with("ssh:"))
.filter(|a| !tracker.is_confirmed(&a.pane_id, a.updated_ts.unwrap_or(0)))
.filter_map(|a| {
mux.capture_pane(&a.pane_id, 5)
Expand Down Expand Up @@ -2572,6 +2601,7 @@ mod tests {
window_pane_counts: HashMap::new(),
},
captured_panes: captures,
remote_active_pane_id: None,
sort: crate::config::SidebarSort::default(),
now,
now_ts,
Expand Down
5 changes: 5 additions & 0 deletions src/command/sidebar/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,11 @@ pub fn navigate(action: NavAction) -> Result<()> {
};

let target_pane = panes[target_idx];
if target_pane.starts_with("ssh:") {
crate::multiplexer::remote_pane_jump(target_pane)?;
daemon_ctrl::signal_daemon();
return Ok(());
}
Cmd::new("tmux")
.args(&["switch-client", "-t", target_pane])
.run()?;
Expand Down
5 changes: 5 additions & 0 deletions src/command/sidebar/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ pub struct SidebarSnapshot {
/// Pane IDs of agents manually marked as sleeping by the user.
#[serde(default)]
pub sleeping_pane_ids: HashSet<String>,
/// Remote agent highlighted as active (recorded on jump-to-remote, valid
/// until the user switches to a different local window).
#[serde(default)]
pub remote_active_pane_id: Option<String>,
pub agents: Vec<AgentPane>,
/// Increments whenever the daemon reloads the merged config.
/// Clients use this to trigger their own per-project config reload.
Expand Down Expand Up @@ -187,6 +191,7 @@ pub fn build_snapshot(
check_statuses,
interrupted_pane_ids: HashSet::new(),
sleeping_pane_ids: live_sleeping,
remote_active_pane_id: None,
agents,
config_version: 0,
}
Expand Down
3 changes: 3 additions & 0 deletions src/command/sidebar/template/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ impl<'a> RowContext<'a> {
pub fn resolve(&self, token: TokenId) -> String {
match token {
TokenId::Primary => self.primary.clone(),
TokenId::Remote => crate::multiplexer::remote_host(&self.agent.pane_id)
.map(|h| format!("@{h}"))
.unwrap_or_default(),
TokenId::Secondary => self.secondary.clone(),
TokenId::Worktree => self.worktree_name(),
TokenId::Project => self.project_name(),
Expand Down
Loading