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:
+6
-4
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user