2026-10-09 23:25:45 -05:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
|
|
|
|
|
*
|
|
|
|
|
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
|
|
|
|
|
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
|
|
|
|
|
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
|
|
|
|
|
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
|
|
|
|
|
* while the remote pane, where the shell really runs, keeps all of it. A full-history
|
|
|
|
|
* load of a remote session reads the scrollback from there instead, and the caller
|
|
|
|
|
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
|
|
|
|
|
* the local pane is what the live stream keeps drawing into, so its frame and caret
|
|
|
|
|
* position are the ones the browser must end up with.
|
|
|
|
|
*
|
2026-10-11 07:53:30 -05:00
|
|
|
* ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S -<N>
|
|
|
|
|
* -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no
|
|
|
|
|
* history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it
|
|
|
|
|
* as history. The screen closes the seam between the remote read and the local
|
|
|
|
|
* frame, which is read only after the round trip: rows that scrolled in between,
|
2026-10-10 23:32:04 -05:00
|
|
|
* and the top of a remote screen taller than the local pane, are on that screen
|
|
|
|
|
* alone, and the caller keeps the screen rows above where the local frame overlaps
|
|
|
|
|
* it (`joinScrollbackAndFrame`).
|
2026-10-09 23:25:45 -05:00
|
|
|
*
|
2026-10-11 07:53:30 -05:00
|
|
|
* ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux
|
|
|
|
|
* runs. tmux runs a client's command list back to back without reading pane output
|
|
|
|
|
* in between, so the history ends exactly where the screen begins. As separate runs
|
|
|
|
|
* the shell kept writing between them, and the rows that scrolled in those
|
|
|
|
|
* milliseconds were in neither capture (measured on a busy pane: every read lost
|
|
|
|
|
* rows). This rests on how tmux's command queue behaves, not on a documented
|
|
|
|
|
* guarantee. tmux output cannot carry the NUL markers, so a random marker line
|
|
|
|
|
* printed by `display-message` separates the history from the size and screen.
|
2026-10-11 18:54:20 -05:00
|
|
|
* tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be
|
|
|
|
|
* glued onto an unterminated last history row; the parser accepts it there too.
|
2026-10-11 07:53:30 -05:00
|
|
|
*
|
2026-10-09 23:25:45 -05:00
|
|
|
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
|
|
|
|
|
* output) returns null and the caller keeps the local capture, so a remote load is
|
2026-10-10 23:28:25 -05:00
|
|
|
* never worse than before. A transport failure also starts a short per-host back-off,
|
|
|
|
|
* so a host that is asleep costs one timeout, not one per tab switch. The child runs
|
|
|
|
|
* under the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other
|
|
|
|
|
* short-lived remote ssh, but gives up on a slot it waited too long for (`maxWaitMs`,
|
|
|
|
|
* enforced in the queue), and the connection options come from
|
|
|
|
|
* `buildSshConnectionArgs()`.
|
2026-10-09 23:25:45 -05:00
|
|
|
*
|
|
|
|
|
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
|
|
|
|
|
* the remote user's tmux.conf raises it); the launch command does not set it.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { exec } from 'node:child_process';
|
2026-10-11 07:53:30 -05:00
|
|
|
import { randomBytes } from 'node:crypto';
|
2026-10-09 23:25:45 -05:00
|
|
|
import { promisify } from 'node:util';
|
|
|
|
|
import type { SessionRemote } from './types/session.js';
|
2026-10-10 23:28:25 -05:00
|
|
|
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
|
|
|
|
|
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
2026-10-09 23:25:45 -05:00
|
|
|
import { remoteTmuxLocation } from './tmux-manager.js';
|
|
|
|
|
|
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bound on the whole round trip. A full load waits on it, so it is far below the
|
|
|
|
|
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
|
|
|
|
|
* is the better answer.
|
|
|
|
|
*/
|
|
|
|
|
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Longest a load waits for a slot in the shared ssh pool, which file previews and
|
|
|
|
|
* attachment probes (20 s each) also hold. Past it the local capture is used.
|
|
|
|
|
*/
|
|
|
|
|
const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
|
|
|
|
|
|
|
|
|
|
/** After a failed read, skip that host for this long and use the local capture. */
|
|
|
|
|
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
|
|
|
|
|
|
2026-10-10 23:32:04 -05:00
|
|
|
/**
|
2026-10-11 07:53:30 -05:00
|
|
|
* Headroom over the remote `tail -c` bound for what it does not cover: the two NUL
|
|
|
|
|
* markers and anything a login banner or rc file prints before the script.
|
2026-10-10 23:32:04 -05:00
|
|
|
*/
|
2026-10-11 07:53:30 -05:00
|
|
|
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Added to the configured byte cap for the remote `tail -c` bound. That bound
|
|
|
|
|
* covers the whole tmux output, and the marker, the size and the screen come last
|
|
|
|
|
* (a wide, tall screen of colored rows runs to a few hundred KB); without this, a
|
|
|
|
|
* small cap would cut into the screen and lose the read. The route trims what it
|
|
|
|
|
* returns to the configured cap anyway, keeping the newest bytes.
|
|
|
|
|
*/
|
|
|
|
|
const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */
|
|
|
|
|
export function remoteTailBytes(maxBytes: number): number {
|
|
|
|
|
return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0;
|
|
|
|
|
}
|
2026-10-09 23:25:45 -05:00
|
|
|
|
|
|
|
|
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
|
|
|
|
|
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
|
|
|
|
|
* sessions costs one timeout, and the map is bounded by the configured hosts.
|
|
|
|
|
*/
|
|
|
|
|
const backoffUntil = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-11 07:53:30 -05:00
|
|
|
* A fresh marker line for one read. Random, so no row the pane really holds can be
|
|
|
|
|
* taken for it.
|
|
|
|
|
*/
|
|
|
|
|
export function newScrollbackMarker(): string {
|
|
|
|
|
return `codeman-history-${randomBytes(8).toString('hex')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The remote shell script. It prints a NUL, the tmux output, then a second NUL:
|
|
|
|
|
* the first separates a login banner or an rc-file `echo` printed before the
|
|
|
|
|
* script runs without guessing (the same marker `buildRemoteProbeCommand` uses),
|
|
|
|
|
* and both are printed whether or not tmux succeeds, so their presence says the
|
|
|
|
|
* script ran on the host. The tmux output is the history rows, `marker`, the
|
|
|
|
|
* history size, then the screen rows.
|
2026-10-09 23:25:45 -05:00
|
|
|
*/
|
|
|
|
|
export function buildRemoteScrollbackScript(
|
|
|
|
|
socket: string,
|
|
|
|
|
sessionName: string,
|
|
|
|
|
historyLines: number,
|
2026-10-11 07:53:30 -05:00
|
|
|
tailBytes: number,
|
|
|
|
|
marker: string
|
2026-10-09 23:25:45 -05:00
|
|
|
): string {
|
|
|
|
|
// `=name:` is an EXACT session match, then its active pane. A bare name is
|
|
|
|
|
// read as a pane index or window name first, so a discovered session named
|
|
|
|
|
// `0` would capture some other session's pane.
|
|
|
|
|
const target = shellescape(`=${sessionName}:`);
|
|
|
|
|
const lines = Math.max(1, Math.trunc(historyLines));
|
|
|
|
|
// The newest rows are the ones worth keeping, so the byte bound is applied on
|
|
|
|
|
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
|
2026-10-11 07:53:30 -05:00
|
|
|
// It covers the whole tmux output, history first, so with the screen
|
|
|
|
|
// allowance in `remoteTailBytes()` a cut lands in the history. 0 means
|
|
|
|
|
// unbounded, as in the route.
|
|
|
|
|
const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : '';
|
|
|
|
|
// ONE tmux command list, so no pane output is read between the history and
|
|
|
|
|
// the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`.
|
2026-10-09 23:25:45 -05:00
|
|
|
return (
|
2026-10-11 07:53:30 -05:00
|
|
|
`printf '\\0'; tmux -L ${shellescape(socket)} ` +
|
|
|
|
|
`capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` +
|
|
|
|
|
`display-message -p -t ${target} ${shellescape(marker)} \\; ` +
|
|
|
|
|
`display-message -p -t ${target} '#{history_size}' \\; ` +
|
|
|
|
|
`capture-pane -p -e -J -t ${target}${bound}; printf '\\0'`
|
2026-10-09 23:25:45 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
|
|
|
|
|
export function buildRemoteScrollbackCommand(
|
|
|
|
|
remote: SessionRemote,
|
|
|
|
|
sessionId: string,
|
|
|
|
|
historyLines: number,
|
2026-10-11 07:53:30 -05:00
|
|
|
tailBytes: number,
|
|
|
|
|
marker: string
|
2026-10-09 23:25:45 -05:00
|
|
|
): string {
|
|
|
|
|
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
|
2026-10-11 07:53:30 -05:00
|
|
|
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
|
2026-10-09 23:25:45 -05:00
|
|
|
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
|
|
|
|
|
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 23:32:04 -05:00
|
|
|
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
|
|
|
|
|
export interface RemoteScrollback {
|
|
|
|
|
/** History rows above the screen, oldest first; '' when the pane has none. */
|
|
|
|
|
scrollback: string;
|
2026-10-11 07:53:30 -05:00
|
|
|
/** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */
|
2026-10-10 23:32:04 -05:00
|
|
|
screen: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 23:25:45 -05:00
|
|
|
/**
|
2026-10-10 23:32:04 -05:00
|
|
|
* The scrollback and screen rows from the script's stdout, or null when the
|
2026-10-11 07:53:30 -05:00
|
|
|
* output is not the script's or tmux printed nothing usable (no such session,
|
2026-10-11 18:54:20 -05:00
|
|
|
* or a byte cap so small it cut into the screen). The marker may end an
|
|
|
|
|
* unterminated history row (tmux before 3.2); when the output reached
|
|
|
|
|
* `tailBytes`, the history's partial first row is dropped.
|
2026-10-09 23:25:45 -05:00
|
|
|
*/
|
2026-10-11 07:53:30 -05:00
|
|
|
export function parseRemoteScrollbackOutput(
|
|
|
|
|
stdout: string,
|
|
|
|
|
marker: string,
|
|
|
|
|
tailBytes: number
|
|
|
|
|
): RemoteScrollback | null {
|
2026-10-09 23:25:45 -05:00
|
|
|
const start = stdout.indexOf('\0');
|
|
|
|
|
if (start === -1) return null;
|
2026-10-11 07:53:30 -05:00
|
|
|
const end = stdout.indexOf('\0', start + 1);
|
|
|
|
|
if (end === -1) return null;
|
2026-10-11 18:54:20 -05:00
|
|
|
// The marker ends a row, after the history's last row. Usually it is a row of
|
|
|
|
|
// its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so
|
|
|
|
|
// when the history ends inside a wrapped line (`-J` leaves it unterminated)
|
|
|
|
|
// the marker is glued onto that row. It is random per read, so no row the
|
|
|
|
|
// pane really holds ends with it.
|
2026-10-11 07:53:30 -05:00
|
|
|
const markerRow = `${marker}\n`;
|
|
|
|
|
const raw = stdout.slice(start + 1, end);
|
2026-10-11 18:54:20 -05:00
|
|
|
const at = raw.indexOf(markerRow);
|
|
|
|
|
if (at === -1) return null;
|
2026-10-11 07:53:30 -05:00
|
|
|
const sizeStart = at + markerRow.length;
|
2026-10-11 18:54:20 -05:00
|
|
|
const sizeEnd = raw.indexOf('\n', sizeStart);
|
2026-10-09 23:25:45 -05:00
|
|
|
if (sizeEnd === -1) return null;
|
2026-10-11 18:54:20 -05:00
|
|
|
const sizeText = raw.slice(sizeStart, sizeEnd).trim();
|
2026-10-09 23:25:45 -05:00
|
|
|
if (!/^\d+$/.test(sizeText)) return null;
|
2026-10-11 18:54:20 -05:00
|
|
|
const screen = raw.slice(sizeEnd + 1);
|
2026-10-10 23:32:04 -05:00
|
|
|
// With no history at all, tmux clamps `-E -1` to the first visible row.
|
|
|
|
|
if (Number(sizeText) === 0) return { scrollback: '', screen };
|
2026-10-11 18:54:20 -05:00
|
|
|
// A tail -c cut lands in the history (the bound leaves room for the rest),
|
|
|
|
|
// mid-row and possibly mid-escape, so the first, partial row is dropped. A
|
|
|
|
|
// cut exactly on the marker row leaves no history at all.
|
|
|
|
|
let scrollback = raw.slice(0, at);
|
|
|
|
|
if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) {
|
|
|
|
|
const newline = scrollback.indexOf('\n');
|
|
|
|
|
scrollback = newline === -1 ? '' : scrollback.slice(newline + 1);
|
|
|
|
|
}
|
|
|
|
|
if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n';
|
|
|
|
|
return { scrollback, screen };
|
2026-10-09 23:25:45 -05:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 23:28:25 -05:00
|
|
|
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
|
|
|
|
|
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
|
|
|
|
|
|
|
|
|
|
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
|
|
|
|
|
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
|
|
|
|
|
|
2026-10-09 23:25:45 -05:00
|
|
|
/**
|
|
|
|
|
* Read a remote session's scrollback from the remote tmux, or null to keep the
|
|
|
|
|
* local capture. Never throws.
|
2026-10-10 23:28:25 -05:00
|
|
|
*
|
|
|
|
|
* Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a
|
|
|
|
|
* spawn error, output that is not the script's): those say the host is
|
|
|
|
|
* unreachable for every session on it. A remote command failing (a session
|
2026-10-11 07:53:30 -05:00
|
|
|
* that is gone: tmux answers nothing between the NULs, and the closing `printf`
|
|
|
|
|
* hides its exit status) is about this session only, so it returns
|
2026-10-10 23:28:25 -05:00
|
|
|
* null and the next session on that host still tries. Same split as
|
|
|
|
|
* `classifyRemoteAliveExit()`.
|
|
|
|
|
*
|
|
|
|
|
* `run` is a test seam: without it nothing runs under vitest, as in
|
|
|
|
|
* remote-files.ts / remote-hosts.ts.
|
2026-10-09 23:25:45 -05:00
|
|
|
*/
|
|
|
|
|
export async function fetchRemoteScrollback(
|
|
|
|
|
remote: SessionRemote,
|
|
|
|
|
sessionId: string,
|
|
|
|
|
historyLines: number,
|
2026-10-10 23:28:25 -05:00
|
|
|
maxBytes: number,
|
|
|
|
|
run?: RemoteScrollbackRunner
|
2026-10-10 23:32:04 -05:00
|
|
|
): Promise<RemoteScrollback | null> {
|
2026-10-10 23:28:25 -05:00
|
|
|
if (!run && process.env.VITEST) return null;
|
|
|
|
|
const runner = run ?? execRunner;
|
2026-10-09 23:25:45 -05:00
|
|
|
const until = backoffUntil.get(remote.hostId);
|
|
|
|
|
if (until !== undefined) {
|
2026-10-10 23:28:25 -05:00
|
|
|
if (Date.now() < until) return null;
|
2026-10-09 23:25:45 -05:00
|
|
|
backoffUntil.delete(remote.hostId);
|
|
|
|
|
}
|
2026-10-11 07:53:30 -05:00
|
|
|
const marker = newScrollbackMarker();
|
|
|
|
|
const tailBytes = remoteTailBytes(maxBytes);
|
|
|
|
|
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker);
|
2026-10-09 23:25:45 -05:00
|
|
|
try {
|
2026-10-10 23:28:25 -05:00
|
|
|
const stdout = await runWithRemoteSshLimit(
|
|
|
|
|
() =>
|
|
|
|
|
runner(command, {
|
|
|
|
|
timeout: REMOTE_HISTORY_TIMEOUT_MS,
|
2026-10-11 07:53:30 -05:00
|
|
|
maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
|
2026-10-10 23:28:25 -05:00
|
|
|
}),
|
|
|
|
|
// The pool is shared with 20 s file probes; past this the local capture
|
|
|
|
|
// is the better answer than waiting behind them.
|
|
|
|
|
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
|
|
|
|
|
);
|
2026-10-11 07:53:30 -05:00
|
|
|
const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes);
|
2026-10-10 23:32:04 -05:00
|
|
|
if (parsed === null) {
|
2026-10-11 07:53:30 -05:00
|
|
|
// Both NULs arrived, so the script ran on the host and only tmux had
|
|
|
|
|
// nothing to say: no such session. The script's last command is the
|
|
|
|
|
// closing `printf`, so this comes back as exit 0 rather than tmux's
|
|
|
|
|
// exit 1, and it is about this session alone. Output without the NULs
|
|
|
|
|
// is not the script's at all, which says the host is not answering as
|
|
|
|
|
// expected.
|
|
|
|
|
const parts = stdout.split('\0');
|
|
|
|
|
const ranOnHost = parts.length >= 3;
|
2026-10-10 23:28:25 -05:00
|
|
|
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
2026-10-11 07:53:30 -05:00
|
|
|
// tmux printed something, but not the marker, size and screen in order.
|
|
|
|
|
const unreadable = ranOnHost && parts[1] !== '';
|
|
|
|
|
const what = !ranOnHost
|
|
|
|
|
? 'unexpected output'
|
|
|
|
|
: unreadable
|
|
|
|
|
? 'unreadable remote tmux output'
|
|
|
|
|
: 'no such remote tmux session';
|
2026-10-10 23:28:25 -05:00
|
|
|
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
|
2026-10-09 23:25:45 -05:00
|
|
|
return null;
|
|
|
|
|
}
|
2026-10-11 07:53:30 -05:00
|
|
|
return parsed;
|
2026-10-09 23:25:45 -05:00
|
|
|
} catch (err) {
|
2026-10-10 23:28:25 -05:00
|
|
|
// A busy pool says nothing about the host.
|
|
|
|
|
if (err instanceof RemoteSshSlotTimeoutError) return null;
|
2026-10-09 23:25:45 -05:00
|
|
|
const e = err as { killed?: boolean; code?: unknown };
|
2026-10-10 23:28:25 -05:00
|
|
|
const code = typeof e.code === 'number' ? e.code : null;
|
|
|
|
|
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
|
|
|
|
|
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
|
|
|
|
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
|
2026-10-09 23:25:45 -05:00
|
|
|
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Test hook: forget every host back-off. */
|
|
|
|
|
export function resetRemoteScrollbackBackoff(): void {
|
|
|
|
|
backoffUntil.clear();
|
|
|
|
|
}
|