From cd427d218b26227347acf98cc33700e8bfd516d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ng=E1=BB=8Dc=20Tr=C3=AD=20V=C4=A9?= Date: Thu, 15 Oct 2026 11:46:09 +0700 Subject: [PATCH] fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server tests on Windows no longer reach the user's real psmux: psmux ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and the runner's kill-server ended every live lane pane — the pre-commit hook included. scripts/test-server.js now takes tmux off the suites' PATH there. - Wheel scrolling on Windows: psmux never turns mouse tracking on for its client, so xterm.js never reported the wheel and a pane could not scroll. A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel report itself; psmux scrolls or forwards it to the pane's app. - tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7 -> brew upgrade tmux / build the release on Linux). Used by the doctor, GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a sticky toast on the Workspace page. Advice only: nothing installs tmux. Committed with --no-verify (user's call): the pre-commit hook runs the full backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only tests: sudo, /proc, systemd, members). Every test touching this change passes: pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the client tests related to the changed files; tsc and the header audit are clean. --- bin/agenthub.js | 10 +++--- client/src/components/run/TerminalView.tsx | 15 +++++++- .../run/__tests__/TerminalView.test.tsx | 18 ++++++++++ client/src/i18n/locales/en/lanes.json | 3 +- client/src/i18n/locales/vi/lanes.json | 3 +- client/src/lib/api.ts | 8 +++-- client/src/pages/Workspace.tsx | 19 ++++++++++ client/src/pages/__tests__/Workspace.test.tsx | 17 +++++++++ docs/API.md | 4 +-- docs/CLI.md | 2 +- scripts/plugin-doctor.js | 33 ++++++----------- scripts/test-server.js | 17 +++++++++ server/__tests__/plugin-doctor-tmux.test.js | 19 +++++----- server/__tests__/pty-attach.test.js | 20 ++++++++++- server/__tests__/run.test.js | 9 +++++ server/__tests__/tmux.test.js | 17 +++++++++ server/lib/pty-attach.js | 5 +++ server/lib/tmux.js | 35 +++++++++++++++++++ server/routes/run.js | 9 ++++- 19 files changed, 218 insertions(+), 45 deletions(-) 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) => {