Skip to content

feat: support session mode and the full test suite on Rex - #345

Open
agawish wants to merge 13 commits into
raine:mainfrom
agawish:rex-session-mode
Open

agawish wants to merge 13 commits into
raine:mainfrom
agawish:rex-session-mode

Conversation

@agawish

@agawish agawish commented Nov 2, 2026

Copy link
Copy Markdown

Stacked on #344. Until that one merges, this diff also shows its commits. The new work is the last two commits.

Summary

In #344 I left the Rex suite at 572 passed and 65 failed, mostly because session mode was tmux-only. This PR adds session mode on Rex and gets the full suite passing there.

I use session mode with Rex every day, so each worktree gets its own Rex session with its windows: layout.

What changed

  • Multiplexer::supports_session_mode() replaces the tmux name checks in create.rs and open.rs. It's true for tmux and Rex.
  • Rex panes start with $SHELL -l, the same as tmux. Without a command, a Rex terminal goes through login(1), which resets HOME and PATH, so panes lost the environment workmux runs in.
  • send_text skips empty text, because Rex rejects it.
  • workmux remove from inside its own session now finishes the cleanup on Rex. The deferred worker waits for the session to close by its ID, and Rex never reported one. Rex now does, and session_exists takes an ID too.
  • The worker inherits REX_SESSION and REX_BLOCK from the session it just closed. rex do fails on those when more than one other session is open, so the pane snapshot runs without them. I added Cmd::env_remove for that.

Tests

  • RexEnvironment now starts a private Rex server for each test, on its own socket, state dir and empty config, like the tmux tests do. The Rex app doesn't need to run, and your own sessions are never touched.
  • I added backend-neutral helpers for sessions and windows, such as list_sessions, new_session, session_pane_path and send_keys_to_session. The session, naming, basic and headless tests use them in place of env.tmux(...).
  • New test_remove_from_inside_session. It keeps a second session open, which is what caught the rex do problem.

Testing

  • cargo test passes, 2165 tests.
  • Rex: 785 passed, 5 failed.
  • tmux: 784 passed, 6 failed.
  • The same 5 fail on main for me: two dashboard base branch tests, two OpenCode v2 setup tests and test_setup_token_reaches_claude_panes_on_the_host.
  • The 6th on tmux is test_add_prompt_file_injects_into_gemini. It's flaky under -n 8 and passes on its own.
  • I tested workmux remove from inside a session-mode worktree by hand on a real Rex server with several sessions open. The session, worktree and branch are all gone afterwards.

Docs

The session mode guide, README, add and open references, the Rex guide and CONTRIBUTING now say session mode works on tmux and Rex.

Switching the app to another session still needs Remote Control in Rex. Without it, workmux prints the session to open, same as in #344.

Rex (https://www.superlogical.com/rex) is detected via $REX_BLOCK, after
tmux and Zellij so a multiplexer started inside Rex still wins. Rex
sessions map to sessions, windows (tabs) to windows, and blocks to panes.

The backend drives Rex only through the rex CLI. Pane listing runs one
Lua script through `rex do`, which returns every pane on the server with
its process, working directory and title in a single call.

Refs raine#341
Add RexEnvironment so the suite can run with --backend=rex. Each test
gets its own session on the running Rex server and removes it after,
like the WezTerm workspaces. The 'test:' target follows tmux and sends
to the session's active pane.
Behaviour:
- Pass `--` to `rex send` so text starting with "-" isn't read as a flag
- Capture with `--trim` so previews don't end in blank rows
- Scope `block.close` to the pane's session; it failed outside it
- Make switching the Rex app's session best effort. Without Remote
  Control, print the session to open instead of failing after the
  work is done
- List sessions that have no panes, so session_exists and kill_session
  see them, and prefer the current session when labels repeat
- Check Rex last in detection, so kitty started from a Rex pane wins,
  and ignore an empty REX_BLOCK
- Return an error from resolve_instance_id when REX_SERVER is missing,
  like WezTerm and kitty
- Implement zoom_pane, rename_window, rename_window_at_pane and
  rename_session

Structure:
- Name detection signals in a struct instead of five bools
- Derive the accepted WORKMUX_BACKEND names from BackendType::ALL
- Share REX_BLOCK detection with the status hook
- Pull repeated lookups and JSON into helpers, in rex.rs and conftest.py

Docs:
- Add a Rex guide page and mention Rex in the README, the guide index
  and CONTRIBUTING
- Add Multiplexer::supports_session_mode, true for tmux and Rex, and
  use it instead of name checks in create and open
- Start Rex panes as `$SHELL -l` without `shell: "none"`, which routed
  terminals through login(1) and reset HOME and PATH
- Skip empty text in Rex send
- Run each Rex test on a private Rex server, like tmux's private socket
- Make session test helpers backend neutral: list_sessions,
  list_windows_in_session, session_pane_path, new_session
- Document session mode on Rex
The deferred cleanup worker waits for the source session to close by its
stable ID, but the Rex backend never reported one, so the worker stopped
and left the worktree and branch behind.

- Report the current session ID from REX_SESSION, and close sessions by ID.
- Let session_exists take a session ID as well as a label.
- Run the snapshot's `rex do` without REX_SESSION and REX_BLOCK. The worker
  inherits them from the session it closed, and `rex do` fails when they
  name a session that is gone and more than one other session exists.
- Add a Cmd::env_remove option for that.
- Test removal from inside a session on tmux and Rex, with a second
  session open.
@agawish agawish mentioned this pull request Nov 2, 2026
@agawish

agawish commented Nov 2, 2026

Copy link
Copy Markdown
Author

This one includes all of #344, so merging it alone covers both. I split them because I thought two smaller PRs would be easier to read. #344 is the backend itself, and this PR adds session mode, the full test suite on Rex and the remove fix on top.

If you merge #344 first, this diff shrinks to the last two commits. If you'd rather do it in one go, merge this and close #344.

kong109 added a commit to kong109/workrex that referenced this pull request Nov 3, 2026
Detection checks Rex last, after tmux, WezTerm, Zellij and kitty, since
every terminal started from a Rex pane inherits REX_BLOCK. Detection
signals move into a BackendSignals struct, and BackendType::ALL with
expected_names() lists the backends in one place. The mod.rs and
types.rs seams are taken from raine#345 so the fork stays
rebaseable on it; supports_session_mode is left to WR-3.

Label lookups (kill_session, kill_window and the rest) now act on one
match, the caller's own session or window when it matches, never every
window that shares the label.

Rex key names were checked live against cat -v: delete, pageup and
pagedown are what Rex documents and accepts, and dc is rejected. An
empty send_text makes no call. Agent scans skip floating layers, which
hold the dashboard and sidebar. A refused app switch says to turn on
Remote Control in Rex Settings.

An unsupported server now stops add with the version message instead
of reporting that Rex is not running.

Approach follows raine#344 by agawish

Co-authored-by: Amr Gawish <amr@gawi.sh>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kong109 added a commit to kong109/workrex that referenced this pull request Nov 3, 2026
A refused or ambiguous `session.select` failed `add` and `open` after
the worktree and window were already made. The switch is now a courtesy:
workmux asks one app client with `-C`, and on refusal prints the Remote
Control fix once and carries on.

Only the app showing the caller's pane is moved, as tmux moves only the
clients on the caller's session. Rex.app attaches to every session in its
sidebar, so `session_ids` alone would still move an app showing another
session; the app must also own the caller's block.

Call-site tests now cover the app switch, one-match kills, the parent
session lookup and the pane scans over onboarding and floating blocks.
The version check is keyed on the canonical socket, `workmux list` names
an unsupported server instead of listing nothing, the unread
`WindowRecord.block_id` is gone, and the Rex test fixture stops its
server when startup fails. rex.mdx describes detection, nesting, window
ownership and Remote Control as they now work.

B2 scan filter and S4 test helpers follow raine#344/raine#345 by agawish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kong109 added a commit to kong109/workrex that referenced this pull request Nov 3, 2026
Unify the Rex.app switch on WR-2's show_on_app: one selector
(app_showing_caller, deciding through shows_caller), one select path
(rex -C <app> do session.select). WR-3's select_on_app, app_clients_for
and client.perform select are deleted; session close and the deferred
close now move only the app showing the caller.

show_on_app returns AppSwitch { Shown, Refused, NoApp } and prints
nothing. CLI paths print the Remote Control fix once; the new
jump_to_pane trait method fails on a refusal, so the dashboard shows the
fix in its status line and stays open instead of closing after a jump
that never happened.

select_pane no longer switches the app, as tmux select-pane moves no
client, so a window-mode add asks the app once. The registry no longer
lists sessions outside its lock: create records without listing, and
closing prunes gone sessions under the lock instead of forgetting the
closing session before its deferred destroy. The session-mode error
text matches raine#345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kong109

kong109 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

I've been running workmux on Rex for a while too, and ran into a few Rex behaviors that #345 may hit. Sharing in case it saves you some time. All of these are on Rex 0.0.1793377904 (API revision 8), and I'm reporting them to the Rex team.

  • REX_SESSION scopes explicit block ids. With REX_SERVER and REX_SESSION set, which is true in every Rex pane, rex block call -b <full id> and send-key -b report "not found" for a block in another session. Stripping REX_SESSION and REX_BLOCK from child rex calls avoids it.
  • An unreachable REX_SERVER falls back silently to the default socket. With an isolated test server that's busy or stopped, calls can land on the real server. -S doesn't fall back, and --autostart=false helps too.
  • rex server stop ignores REX_SERVER and stops the default server. In a test fixture, stop the server by pid.
  • A new session's starting windows get no window_created event, only session_created.
  • A floating layer that closes because its program exited doesn't return focus to the block that had it. session.close_layer does.

If #345 lands, I'd be glad to send small follow-ups for things I've needed: honoring after_window with session.move_window, real bracketed paste with a block.event paste, and selecting the next session before destroying one.

@agawish

agawish commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

Thanks, this is really useful! Hadn't come across workrex before, nice to see someone else running workmux on Rex every day.

I went through the quirks against #345:

  • Every call that names a block passes -s with -b. The snapshot script names the session in Lua, and since the remove fix it runs without REX_SESSION and REX_BLOCK.
  • The test fixture always calls rex --autostart=false -S <private socket>, so it can't fall back to the real server.
  • The fixture stops its server by terminating the process. It never calls rex server stop.
  • workmux doesn't listen to Rex events, so the missing window_created doesn't make sense here.

The floating layer one doesn't affect workmux, but it explains something I'd noticed with popups in my own Rex config.

And yes please to the follow-ups once #345 lands: after_window with session.move_window, the bracketed paste, and selecting the next session before destroying one. I'd also love your change that makes a refused jump fail, so the dashboard shows the Remote Control fix and stays open. Right now mine closes the dashboard after a jump that never happened. Moving only the app that shows the caller's block, from cb983a2, would be welcome too.

@logan-broit

Copy link
Copy Markdown

One more Rex issue I hit with #345, separate from the list above.

workmux close <name> fails for a window-mode worktree when you run it from a different Rex session than the one its window is in:

Error: No active window found for 'wm-…'. The worktree exists but has no open window.

Repro: workmux add feat -b --parent-session other, then workmux close feat from your current session.

The worktree records its parent session (window-session), but RexBackend doesn't override the WindowTarget methods. The trait defaults look the label up with get_all_window_names, which on Rex only covers the caller's session. tmux overrides them and targets =session:=window.

Fix: override window_target_exists, kill_window_target, schedule_window_target_close, shell_kill_window_target_cmd and select_window_target in rex.rs to find the window by label within target.parent_session(), falling back to window_panes when there is none. test_close_window_in_parent_session was tmux_only; with the session helpers from #345 it runs on Rex too. At c3ae3bc it fails on Rex without the change and passes with it, and tmux still passes.

Patch against c3ae3bc in case it's useful:

patch
diff --git a/src/multiplexer/rex.rs b/src/multiplexer/rex.rs
index aeb4af01..e04675cc 100644
--- a/src/multiplexer/rex.rs
+++ b/src/multiplexer/rex.rs
@@ -237,6 +237,30 @@ impl RexSnapshot {
             .next()
             .ok_or_else(|| anyhow!("Window '{}' not found", full_name))
     }
+
+    /// The window a target names: by label within its parent session when it
+    /// has one, otherwise as `window_panes` resolves the label.
+    fn target_window(&self, target: &WindowTarget) -> Option<&RexPane> {
+        match target.parent_session() {
+            Some(session) => self
+                .panes
+                .iter()
+                .find(|p| !p.floating && p.window == target.full_name && p.session == session),
+            None => self.window_panes(&target.full_name).into_iter().next(),
+        }
+    }
+
+    fn require_target_window(&self, target: &WindowTarget) -> Result<&RexPane> {
+        self.target_window(target)
+            .ok_or_else(|| match target.parent_session() {
+                Some(session) => anyhow!(
+                    "Window '{}' not found in session '{}'",
+                    target.full_name,
+                    session
+                ),
+                None => anyhow!("Window '{}' not found", target.full_name),
+            })
+    }
 }
 
 /// A connected client as reported by `rex client ls --json`.
@@ -729,6 +753,55 @@ impl Multiplexer for RexBackend {
         util::run_detached_sh_c(script)
     }
 
+    // Window targets carry the parent session a window-mode worktree was
+    // opened in. Without these, the defaults look only in the session workmux
+    // runs from, so `close` from another session reports no open window.
+
+    fn window_target_exists(&self, target: &WindowTarget) -> Result<bool> {
+        Ok(self.snapshot()?.target_window(target).is_some())
+    }
+
+    fn kill_window_target(&self, target: &WindowTarget) -> Result<()> {
+        let snapshot = self.snapshot()?;
+        if let Some(pane) = snapshot.target_window(target) {
+            self.call_api("session.close_window", pane.window_ref())
+                .context("Failed to close Rex window")?;
+        }
+        Ok(())
+    }
+
+    fn schedule_window_target_close(&self, target: &WindowTarget, delay: Duration) -> Result<()> {
+        let snapshot = self.snapshot()?;
+        let Some(pane) = snapshot.target_window(target) else {
+            return Ok(());
+        };
+        let script = format!(
+            "sleep {}; {}",
+            delay.as_secs_f64(),
+            Self::close_window_shell_cmd(pane)
+        );
+        util::run_detached_sh_c(&script)
+    }
+
+    fn shell_kill_window_target_cmd(&self, target: &WindowTarget) -> Result<String> {
+        let snapshot = self.snapshot()?;
+        Ok(Self::close_window_shell_cmd(
+            snapshot.require_target_window(target)?,
+        ))
+    }
+
+    fn select_window_target(&self, target: &WindowTarget) -> Result<()> {
+        let snapshot = self.snapshot()?;
+        let pane = snapshot.require_target_window(target)?;
+        if !Self::is_current_session(&pane.session_id) {
+            let label = snapshot.session_label(&pane.session_id).unwrap_or_default();
+            self.show_session(&pane.session_id, label, Some(&pane.window_id));
+        }
+        self.call_api("session.focus_window", pane.window_ref())
+            .context("Failed to focus Rex window")?;
+        Ok(())
+    }
+
     fn shell_select_window_cmd(&self, full_name: &str) -> Result<String> {
         let snapshot = self.snapshot()?;
         let pane = snapshot.require_window(full_name)?;
diff --git a/tests/test_workmux_close.py b/tests/test_workmux_close.py
index 6c606a71..147d21ab 100644
--- a/tests/test_workmux_close.py
+++ b/tests/test_workmux_close.py
@@ -182,15 +182,24 @@ def test_close_does_not_adopt_same_name_window_from_another_directory(
     assert impostor_id in window_ids
 
 
-@pytest.mark.tmux_only
 def test_close_window_in_parent_session(
-    mux_server: TmuxEnvironment, workmux_exe_path: Path, mux_repo_path: Path
+    mux_server: MuxEnvironment, workmux_exe_path: Path, mux_repo_path: Path
 ):
+    """Close finds a window in its parent session from another session."""
     env = mux_server
+    if env.backend_name not in ("tmux", "rex"):
+        pytest.skip(f"{env.backend_name} has no parent sessions")
     branch_name = "feature/parent-session-close"
     session_name = "prs-close"
     window_name = get_window_name(branch_name)
 
+    def windows_in_parent() -> list[str]:
+        # The parent session may be gone once its last window closes
+        try:
+            return env.list_windows_in_session(session_name)
+        except Exception:
+            return []
+
     write_workmux_config(mux_repo_path)
     run_workmux_command(
         env,
@@ -199,21 +208,12 @@ def test_close_window_in_parent_session(
         f"add {branch_name} --parent-session {session_name} --background",
     )
 
-    result = env.tmux(
-        ["list-windows", "-t", f"{session_name}:", "-F", "#{window_name}"]
-    )
-    assert window_name in [w for w in result.stdout.strip().split("\n") if w]
+    assert window_name in windows_in_parent()
 
+    # Runs from the test session, not the parent session
     run_workmux_close(env, workmux_exe_path, mux_repo_path, branch_name)
 
-    assert poll_until(
-        lambda: window_name
-        not in env.tmux(
-            ["list-windows", "-t", f"{session_name}:", "-F", "#{window_name}"],
-            check=False,
-        ).stdout.splitlines(),
-        timeout=5.0,
-    )
+    assert poll_until(lambda: window_name not in windows_in_parent(), timeout=5.0)
     worktree_path = get_worktree_path(mux_repo_path, branch_name)
     assert worktree_path.exists(), "Worktree should still exist after close"
 

agawish and others added 3 commits November 6, 2026 14:05
`workmux close` failed for a window-mode worktree when run from a
different Rex session than the one its window is in. RexBackend didn't
override the WindowTarget methods, so the trait defaults only looked in
the caller's session.

Override window_target_exists, kill_window_target,
schedule_window_target_close, shell_kill_window_target_cmd and
select_window_target to find the window by label within the target's
parent session, falling back to window_panes when there is none.
test_close_window_in_parent_session now runs on Rex too.

Co-authored-by: logan-broit <29020058+logan-broit@users.noreply.github.com>
@agawish

agawish commented Nov 6, 2026

Copy link
Copy Markdown
Author

Thanks for the patch, nice catch! It's in now as fe715e6, with you as co-author. I checked it fails on Rex without the change and passes with it, and tmux still passes.

@agawish
agawish marked this pull request as ready for review November 8, 2026 00:38
@ficusk

ficusk commented Nov 9, 2026

Copy link
Copy Markdown

Just commenting as a rando to say I've been running a workmux built from this PR for the past few days with Rex and it is working well for me.

@agawish

agawish commented Nov 12, 2026

Copy link
Copy Markdown
Author

@ficusk I've been running it for the past 2 weeks and so, and it has been a treat, I know it is a massive piece of code, so happy to split it however the author likes, but in the same time I know he probably gets a lot of AI driven stuff, so I know he should be wary of anything indeed

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants