fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests

- 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.
This commit is contained in:
Nguyễn Ngọc Trí Vĩ
2026-10-15 11:46:09 +07:00
parent 7a88b74016
commit cd427d218b
19 changed files with 218 additions and 45 deletions
+6 -4
View File
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
// warning, not a failure: it costs the run console and `lanes shell`, nothing // warning, not a failure: it costs the run console and `lanes shell`, nothing
// else. // else.
try { try {
const { available } = await get("/api/run/tmux"); const { available, upgrade } = await get("/api/run/tmux");
console.log( console.log(
available !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. ` +
: `${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` `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 { } catch {
console.log(`${c.dim("·")} tmux not reported by this server`); console.log(`${c.dim("·")} tmux not reported by this server`);
+14 -1
View File
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here // `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so // because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well. // 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 mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10"; 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"; return tracked || term.buffer.active.type !== "alternate";
}); });
@@ -530,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
requestAnimationFrame(scaleToFit); requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") { } else if (msg.type === "lead") {
takeLead(); takeLead();
} else if (msg.type === "wheel") {
ownWheel = true;
} else if (msg.type === "exit") { } else if (msg.type === "exit") {
ended = true; ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`); term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); 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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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", () => { it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
+2 -1
View File
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Another user's home — view and download only", "fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnly": "Read-only", "fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.", "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}}"
} }
+2 -1
View File
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về", "fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnly": "Chỉ đọc", "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.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}}"
} }
+6 -2
View File
@@ -2936,8 +2936,12 @@ export const api = {
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */ /** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") => binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`), request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */ /** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"), * 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. */ /** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs` /** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
+19
View File
@@ -665,6 +665,25 @@ export function Workspace() {
const { local } = usePermissions(); const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams(); 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 // 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 // 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. // notification click), falling back to the last one shown, then the first.
@@ -459,6 +459,23 @@ describe("Workspace page — lane integration", () => {
}); });
describe("Workspace layout", () => { 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<string, unknown>;
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 () => { it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = []; lanesToReturn = [];
await renderWorkspace(); await renderWorkspace();
+2 -2
View File
@@ -2792,7 +2792,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http ```http
GET /api/run List all live runs (computed fresh from tmux state) 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/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent) 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 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 #### /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=<n>&rows=<n>`, 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=<n>&rows=<n>`, 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 #### cc_config_changed
+1 -1
View File
@@ -288,7 +288,7 @@ Because a workspace's services are fully detached, restarting the dashboard (or
| Command | Description | | 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 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 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 | | `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days |
+11 -22
View File
@@ -247,15 +247,12 @@ function systemdStatus() {
* service can still see a different one — `GET /api/run/tmux` is the server's * service can still see a different one — `GET /api/run/tmux` is the server's
* own answer, and `agenthub doctor` reports that. * own answer, and `agenthub doctor` reports that.
* *
* Also WARN below tmux 3.7, the first with pane scrollbars (`tmux.js` turns * Also WARN below the platform's minimum (`tmux.upgradeAdvice`: psmux 3.4.0
* them on), and when a new binary still talks to an older running server — a * on Windows, tmux 3.7 elsewhere — the same advice the Workspace shows), and
* server keeps its version until every tmux session has closed. The doctor * when a new binary still talks to an older running server — a server keeps
* only says so: it never installs or restarts tmux. * 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() { function tmuxStatus() {
const tmux = require("../server/lib/tmux"); 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)", "Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
]; ];
} }
if (!hasScrollbars(client)) { const advice = tmux.upgradeAdvice(client);
return [ if (advice) return ["warn", "tmux", `${client} works, but upgrade it: ${advice}`];
"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 server = tmux.serverVersion(); const server = tmux.serverVersion();
if (server && !hasScrollbars(server)) { if (server && tmux.upgradeAdvice(server)) {
return [ return [
"warn", "warn",
"tmux", "tmux",
`${client} installed, but the running tmux server is ${server} — panes get their ` + `${client} installed, but the running tmux server is ${server} — it is used ` +
"scrollbar once every tmux session has closed (or after a reboot)", "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() { function countLegacyHookEntries() {
+17
View File
@@ -68,6 +68,23 @@ const env = {
}; };
delete env.TMUX; delete env.TMUX;
delete env.TMUX_PANE; 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 // 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 // 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 // its temp folder went to the REAL repository instead — commits on the
+10 -9
View File
@@ -3,9 +3,10 @@
* an install its browser run console cannot work, because every run the * 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 * 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 * 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 * binary, so failing a doctor run over it would cry wolf. A tmux older than its
* (no pane scrollbars) also warns, as does a new binary still talking to an * platform asks for (tmux.upgradeAdvice: psmux 3.4.0 on Windows, tmux 3.7
* older running server. tmux is faked through the module's own exec seam, so * 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. * the test never depends on the host having it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -56,18 +57,18 @@ describe("plugin-doctor: tmux row", () => {
assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok"); assert.equal(rowWithTmux("next-3.8", "3.8-rc3").level, "ok");
}); });
it("warns, with the upgrade path, when tmux is older than 3.7", () => { it("warns, with this platform's upgrade path, when tmux is older than it asks for", () => {
const row = rowWithTmux("3.4", "3.4"); // 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.equal(row.level, "warn");
assert.match(row.detail, /3\.7/); assert.ok(row.detail.includes(tmux.upgradeAdvice("2.9")), row.detail);
assert.match(row.detail, /brew upgrade tmux/);
}); });
it("warns when a new tmux is still talking to an older running server", () => { 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.equal(row.level, "warn");
assert.match(row.detail, /server is 3\.4/); assert.match(row.detail, /server is 2\.9/);
}); });
}); });
+19 -1
View File
@@ -116,7 +116,8 @@ describe("pty-attach", () => {
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => { it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
const ws = makeFakeWs(); 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"); fakePty.__emitter.emit("data", "hello from claude");
assert.equal(ws.__sent.length, 1); assert.equal(ws.__sent.length, 1);
@@ -269,4 +270,21 @@ describe("attachBinary on Windows", () => {
} }
tmux.__reset(); 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();
}
});
}); });
+9
View File
@@ -190,6 +190,15 @@ describe("/api/run", () => {
tmux.__setExecImpl(() => "tmux 3.4"); tmux.__setExecImpl(() => "tmux 3.4");
const { body } = await fetchJson("/api/run/tmux"); const { body } = await fetchJson("/api/run/tmux");
assert.equal(body.available, true); 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 () => { it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
+17
View File
@@ -326,4 +326,21 @@ describe("tmux wrapper", () => {
}); });
assert.equal(tmux.isTmuxAvailable(), false); 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);
});
}); });
+5
View File
@@ -136,6 +136,11 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
group.viewers.add(me); group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows }); if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me; 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 // 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 // queue a chatty pane's output in this process without bound. Past 1 MB
+35
View File
@@ -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 /** 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. */ * an upgrade keeps its old version until every tmux session has closed. */
function serverVersion() { function serverVersion() {
@@ -725,6 +759,7 @@ module.exports = {
isTmuxAvailable, isTmuxAvailable,
clientVersion, clientVersion,
serverVersion, serverVersion,
upgradeAdvice,
serverPid, serverPid,
memberAttach, memberAttach,
__setExecImpl, __setExecImpl,
+8 -1
View File
@@ -423,7 +423,14 @@ router.get("/binary", (req, res) => {
}); });
router.get("/tmux", (_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) => { router.post("/", (req, res) => {