diff --git a/bin/agenthub.js b/bin/agenthub.js
index 6a0d6b93..2159b908 100755
--- a/bin/agenthub.js
+++ b/bin/agenthub.js
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// else.
try {
- const { available } = await get("/api/run/tmux");
+ const { available, upgrade } = await get("/api/run/tmux");
console.log(
- available
- ? `${c.green("✔")} tmux available to the server`
- : `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
+ !available
+ ? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
+ : upgrade
+ ? `${c.yellow("!")} tmux ${upgrade}`
+ : `${c.green("✔")} tmux available to the server`
);
} catch {
console.log(`${c.dim("·")} tmux not reported by this server`);
diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx
index 826ba15a..80d1ba9c 100644
--- a/client/src/components/run/TerminalView.tsx
+++ b/client/src/components/run/TerminalView.tsx
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
- term.attachCustomWheelEventHandler(() => {
+ // psmux (Windows) never turns tracking on, so xterm never reports the
+ // wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
+ // wheel report itself — psmux scrolls, or hands it to the pane's app.
+ // ponytail: at 1;1, which is all psmux reports to the app anyway.
+ let ownWheel = false;
+ term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
+ if (!tracked && ownWheel) {
+ if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
+ }
+ return false;
+ }
return tracked || term.buffer.active.type !== "alternate";
});
@@ -530,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
+ } else if (msg.type === "wheel") {
+ ownWheel = true;
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx
index 88a635bb..08a278f1 100644
--- a/client/src/components/run/__tests__/TerminalView.test.tsx
+++ b/client/src/components/run/__tests__/TerminalView.test.tsx
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
+ it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
+ // psmux (Windows) never turns mouse tracking on for its client, so xterm
+ // never reports the wheel and the pane could not be scrolled at all.
+ termState.modes.mouseTrackingMode = "none";
+ termState.buffer.active.type = "alternate";
+ render();
+ const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
+ ws.onmessage?.({ data: '{"type":"wheel"}' });
+ const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
+ expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
+ expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
+ expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
+ // Tracked again (a tmux that does report): xterm sends it, never twice.
+ termState.modes.mouseTrackingMode = "any";
+ expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
+ expect(ws.sent).toHaveLength(2);
+ });
+
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render();
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json
index 3f10ddcd..727f6223 100644
--- a/client/src/i18n/locales/en/lanes.json
+++ b/client/src/i18n/locales/en/lanes.json
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
- "fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left."
+ "fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
+ "tmuxUpgrade": "Upgrade tmux: {{advice}}"
}
diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json
index 98eb7de2..2ce7e033 100644
--- a/client/src/i18n/locales/vi/lanes.json
+++ b/client/src/i18n/locales/vi/lanes.json
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
- "fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái."
+ "fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
+ "tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index c663782f..d8612caf 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -2936,8 +2936,12 @@ export const api = {
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
- /** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
- tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
+ /** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
+ * version, and this platform's upgrade advice when it is too old. */
+ tmuxAvailable: () =>
+ request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
+ "/run/tmux"
+ ),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx
index 03881e19..ef92cec9 100644
--- a/client/src/pages/Workspace.tsx
+++ b/client/src/pages/Workspace.tsx
@@ -665,6 +665,25 @@ export function Workspace() {
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
+ // The server's tmux is older than this platform needs (psmux < 3.4.0 on
+ // Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
+ // Sticky (an error toast) so the command can be copied. An older server or
+ // no answer says nothing.
+ useEffect(() => {
+ let live = true;
+ (async () => {
+ try {
+ const { upgrade } = await api.run.tmuxAvailable();
+ if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
+ } catch {
+ /* nothing to say */
+ }
+ })();
+ return () => {
+ live = false;
+ };
+ }, [tLanes]);
+
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx
index 3dcedaba..b77e15e3 100644
--- a/client/src/pages/__tests__/Workspace.test.tsx
+++ b/client/src/pages/__tests__/Workspace.test.tsx
@@ -459,6 +459,23 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
+ it("asks the user to upgrade a tmux older than their platform needs", async () => {
+ const { toast } = await import("../../lib/toast");
+ const shown = vi.spyOn(toast, "error");
+ const run = api.run as unknown as Record;
+ run.tmuxAvailable = vi.fn().mockResolvedValue({
+ available: true,
+ version: "3.3.2",
+ upgrade: "psmux 3.3.2 is older than 3.4.0 — winget upgrade marlocarlo.psmux",
+ });
+ onTestFinished(() => {
+ delete run.tmuxAvailable;
+ shown.mockRestore();
+ });
+ await renderWorkspace();
+ expect(shown).toHaveBeenCalledWith(expect.stringContaining("winget upgrade marlocarlo.psmux"));
+ });
+
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();
diff --git a/docs/API.md b/docs/API.md
index b057caf8..5d9e7474 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -2792,7 +2792,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
-GET /api/run/tmux { available: bool } — whether tmux is on PATH
+GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
@@ -3209,7 +3209,7 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
-A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
+A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
diff --git a/docs/CLI.md b/docs/CLI.md
index 2294b5c9..2f5a8800 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -288,7 +288,7 @@ Because a workspace's services are fully detached, restarting the dashboard (or
| Command | Description |
| ------- | ----------- |
-| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile), remote-source status |
+| `agenthub doctor` | Diagnosis: API reachability, hook installation status + path, database path/size/row counts, server uptime and Node version, WS connections, whether `tmux` is on **the server's** PATH (asked via `GET /api/run/tmux` — a systemd `--user` service does not read your shell profile) and, when it is older than the platform needs, how to upgrade it, remote-source status |
| `agenthub info` | The raw `/api/settings/info` JSON (pipe it to `jq`) |
| `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` |
| `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js
index 7f5eef32..51f1ec30 100644
--- a/scripts/plugin-doctor.js
+++ b/scripts/plugin-doctor.js
@@ -247,15 +247,12 @@ function systemdStatus() {
* service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that.
*
- * Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns
- * them on), and when a new binary still talks to an older running server — a
- * server keeps its version until every tmux session has closed. The doctor
- * only says so: it never installs or restarts tmux.
+ * Also WARN below the platform's minimum (`tmux.upgradeAdvice`: psmux 3.4.0
+ * on Windows, tmux 3.7 elsewhere — the same advice the Workspace shows), and
+ * when a new binary still talks to an older running server — a server keeps
+ * its version until every tmux session has closed. The doctor only says so:
+ * it never installs or restarts tmux.
*/
-const hasScrollbars = (version) => {
- const m = /(\d+)\.(\d+)/.exec(version || "");
- return !!m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 7));
-};
function tmuxStatus() {
const tmux = require("../server/lib/tmux");
@@ -268,26 +265,18 @@ function tmuxStatus() {
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
];
}
- if (!hasScrollbars(client)) {
- return [
- "warn",
- "tmux",
- `${client} works, but terminal panes get a scrollbar only from tmux 3.7. ` +
- "Upgrade: brew upgrade tmux (macOS); on Linux, when the distro ships an older one " +
- "(Ubuntu 24.04 has 3.4), build the release from https://github.com/tmux/tmux/releases " +
- "(needs libevent-dev libncurses-dev bison: ./configure && make && sudo make install)",
- ];
- }
+ const advice = tmux.upgradeAdvice(client);
+ if (advice) return ["warn", "tmux", `${client} works, but upgrade it: ${advice}`];
const server = tmux.serverVersion();
- if (server && !hasScrollbars(server)) {
+ if (server && tmux.upgradeAdvice(server)) {
return [
"warn",
"tmux",
- `${client} installed, but the running tmux server is ${server} — panes get their ` +
- "scrollbar once every tmux session has closed (or after a reboot)",
+ `${client} installed, but the running tmux server is ${server} — it is used ` +
+ "until every tmux session has closed (or after a reboot)",
];
}
- return ["ok", "tmux", `${client} on PATH — the run console can start sessions, with scrollbars`];
+ return ["ok", "tmux", `${client} on PATH — the run console can start sessions`];
}
function countLegacyHookEntries() {
diff --git a/scripts/test-server.js b/scripts/test-server.js
index be1b3705..21595bcb 100644
--- a/scripts/test-server.js
+++ b/scripts/test-server.js
@@ -68,6 +68,23 @@ const env = {
};
delete env.TMUX;
delete env.TMUX_PANE;
+// psmux (the WinGet `tmux` on Windows) ignores TMUX_TMPDIR — and 3.3.x its
+// PSMUX_DATA_DIR too — so every tmux call reached the user's REAL server:
+// quit.test.js's Quit killed every live lane pane, and the kill-server below
+// the rest. Only `-L` isolates there, which no env var can add to each call,
+// so on Windows the suites get no tmux at all; theirs skip (or fail) instead.
+if (process.platform === "win32") {
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
+ if (key) {
+ env[key] = env[key]
+ .split(path.delimiter)
+ .filter(
+ (d) => !["tmux.exe", "tmux.cmd", "tmux.bat"].some((f) => fs.existsSync(path.join(d, f)))
+ )
+ .join(path.delimiter);
+ }
+ delete env.PSMUX_SESSION;
+}
// Git exports these to a hook it runs from a linked worktree (the pre-commit
// hook runs this suite): inherited, every `git init`/`commit` a test makes in
// its temp folder went to the REAL repository instead — commits on the
diff --git a/server/__tests__/plugin-doctor-tmux.test.js b/server/__tests__/plugin-doctor-tmux.test.js
index 97f6ea82..c4fd9e93 100644
--- a/server/__tests__/plugin-doctor-tmux.test.js
+++ b/server/__tests__/plugin-doctor-tmux.test.js
@@ -3,9 +3,10 @@
* an install its browser run console cannot work, because every run the
* dashboard starts is a tmux session (server/lib/tmux.js). The row must WARN
* and never FAIL: sessions, analytics, lanes and the CLI all work without the
- * binary, so failing a doctor run over it would cry wolf. A tmux older than 3.7
- * (no pane scrollbars) also warns, as does a new binary still talking to an
- * older running server. tmux is faked through the module's own exec seam, so
+ * binary, so failing a doctor run over it would cry wolf. A tmux older than its
+ * platform asks for (tmux.upgradeAdvice: psmux 3.4.0 on Windows, tmux 3.7
+ * elsewhere) also warns, as does a new binary still talking to an older
+ * running server. tmux is faked through the module's own exec seam, so
* the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -56,18 +57,18 @@ describe("plugin-doctor: tmux row", () => {
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
});
- it("warns, with the upgrade path, when tmux is older than 3.7", () => {
- const row = rowWithTmux("3.4", "3.4");
+ it("warns, with this platform's upgrade path, when tmux is older than it asks for", () => {
+ // 2.9 is too old everywhere; the advice (minimum, command) is per platform.
+ const row = rowWithTmux("2.9", "2.9");
assert.equal(row.level, "warn");
- assert.match(row.detail, /3\.7/);
- assert.match(row.detail, /brew upgrade tmux/);
+ assert.ok(row.detail.includes(tmux.upgradeAdvice("2.9")), row.detail);
});
it("warns when a new tmux is still talking to an older running server", () => {
- const row = rowWithTmux("3.7c", "3.4");
+ const row = rowWithTmux("3.7c", "2.9");
assert.equal(row.level, "warn");
- assert.match(row.detail, /server is 3\.4/);
+ assert.match(row.detail, /server is 2\.9/);
});
});
diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js
index 4938e214..113012fe 100644
--- a/server/__tests__/pty-attach.test.js
+++ b/server/__tests__/pty-attach.test.js
@@ -116,7 +116,8 @@ describe("pty-attach", () => {
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
const ws = makeFakeWs();
- ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
+ // Linux: a Windows attach also says `wheel` first (its own test below).
+ ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform: "linux" });
fakePty.__emitter.emit("data", "hello from claude");
assert.equal(ws.__sent.length, 1);
@@ -269,4 +270,21 @@ describe("attachBinary on Windows", () => {
}
tmux.__reset();
});
+
+ it("tells a Windows attach to send the wheel itself (psmux never tracks the mouse)", () => {
+ for (const [platform, told] of [
+ ["win32", true],
+ ["linux", false],
+ ]) {
+ const ws = makeFakeWs();
+ ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24, platform });
+ const said = ws.__sent.filter((m) => !m.binary).map((m) => JSON.parse(m.data));
+ assert.equal(
+ said.some((m) => m.type === "wheel"),
+ told,
+ platform
+ );
+ ptyAttach.__resetGroups();
+ }
+ });
});
diff --git a/server/__tests__/run.test.js b/server/__tests__/run.test.js
index a96ddb6b..ad9c3e41 100644
--- a/server/__tests__/run.test.js
+++ b/server/__tests__/run.test.js
@@ -190,6 +190,15 @@ describe("/api/run", () => {
tmux.__setExecImpl(() => "tmux 3.4");
const { body } = await fetchJson("/api/run/tmux");
assert.equal(body.available, true);
+ // The version and this platform's upgrade advice (null when new enough).
+ assert.equal(body.version, "3.4");
+ assert.equal(body.upgrade, tmux.upgradeAdvice("3.4"));
+ });
+
+ it("GET /tmux says to upgrade a version older than the platform asks for", async () => {
+ tmux.__setExecImpl(() => "tmux 2.9");
+ const { body } = await fetchJson("/api/run/tmux");
+ assert.match(body.upgrade, /older than/);
});
it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js
index 2ee73af1..1f11095b 100644
--- a/server/__tests__/tmux.test.js
+++ b/server/__tests__/tmux.test.js
@@ -326,4 +326,21 @@ describe("tmux wrapper", () => {
});
assert.equal(tmux.isTmuxAvailable(), false);
});
+ it("upgradeAdvice asks each platform for its own minimum, with its own command", () => {
+ // Windows: psmux before 3.4.0 (the WinGet `tmux`).
+ assert.match(
+ tmux.upgradeAdvice("3.3.2", "win32"),
+ /psmux 3\.3\.2 .*3\.4\.0.*winget upgrade marlocarlo\.psmux/
+ );
+ assert.equal(tmux.upgradeAdvice("3.4.0", "win32"), null);
+ assert.equal(tmux.upgradeAdvice("3.10.1", "win32"), null);
+ // macOS / Linux: tmux before 3.7 (pane scrollbars).
+ assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
+ assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
+ assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
+ assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
+ // Nothing to compare: no advice rather than a wrong one.
+ assert.equal(tmux.upgradeAdvice(null, "linux"), null);
+ assert.equal(tmux.upgradeAdvice("3.3.2", "freebsd"), null);
+ });
});
diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js
index ee120f46..89018e17 100644
--- a/server/lib/pty-attach.js
+++ b/server/lib/pty-attach.js
@@ -136,6 +136,11 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
+ // psmux never turns mouse tracking on for its client (not even for a pane
+ // app that asked), so xterm.js would never report the wheel and the pane
+ // could not scroll. psmux does act on a wheel report it receives: the
+ // browser sends one itself (TerminalView).
+ if (platform === "win32") tell(me, { type: "wheel" });
// Backpressure: a slow viewer (a remote browser on a thin link) must not
// queue a chatty pane's output in this process without bound. Past 1 MB
diff --git a/server/lib/tmux.js b/server/lib/tmux.js
index 746941c4..712c63d2 100644
--- a/server/lib/tmux.js
+++ b/server/lib/tmux.js
@@ -695,6 +695,40 @@ function clientVersion() {
}
}
+// The oldest tmux AgentHub asks for, and how each platform upgrades it.
+// Windows' is psmux: before 3.4.0 a dashboard terminal stalled until a
+// refresh, and fixes for frame push, writes and synchronized output landed in
+// 3.3.6–3.4.0. Elsewhere 3.7, the first with pane scrollbars (enableMouse).
+const UPGRADES = {
+ win32: {
+ min: [3, 4, 0],
+ name: "psmux",
+ how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
+ },
+ darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
+ linux: {
+ min: [3, 7, 0],
+ name: "tmux",
+ how:
+ "distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
+ "https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
+ "./configure && make && sudo make install)",
+ },
+};
+
+/** What to tell the user when `version` is older than their platform asks
+ * for; null when it is new enough or cannot be compared. Advice only —
+ * AgentHub never installs or restarts tmux. */
+function upgradeAdvice(version = clientVersion(), platform = process.platform) {
+ const want = UPGRADES[platform];
+ const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version || "");
+ if (!want || !m) return null;
+ const have = [m[1], m[2], m[3] || 0].map(Number);
+ const i = have.findIndex((n, k) => n !== want.min[k]);
+ if (i === -1 || have[i] > want.min[i]) return null;
+ return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
+}
+
/** The RUNNING server's version, null when none runs. A server started before
* an upgrade keeps its old version until every tmux session has closed. */
function serverVersion() {
@@ -725,6 +759,7 @@ module.exports = {
isTmuxAvailable,
clientVersion,
serverVersion,
+ upgradeAdvice,
serverPid,
memberAttach,
__setExecImpl,
diff --git a/server/routes/run.js b/server/routes/run.js
index 87ce1c96..d30457c8 100644
--- a/server/routes/run.js
+++ b/server/routes/run.js
@@ -423,7 +423,14 @@ router.get("/binary", (req, res) => {
});
router.get("/tmux", (_req, res) => {
- res.json({ available: tmux.isTmuxAvailable() });
+ // `upgrade`: this platform's advice when the binary is older than AgentHub
+ // asks for (tmux.upgradeAdvice), else null. The Workspace shows it.
+ const version = tmux.clientVersion();
+ res.json({
+ available: version !== null,
+ version,
+ upgrade: version ? tmux.upgradeAdvice(version) : null,
+ });
});
router.post("/", (req, res) => {